fix Keep bare and brace-grouped tags in the vocabulary

rail-tools--tag-axes only understood [ ] groups. Tags declared outside a group, or inside a { } group, were dropped, so a file that used them silently accepted any tag. Handle both group markers, and collect ungrouped tags under a misc axis. Add a test for the mixed case. Also declare the two MCP framework functions for the byte compiler, require rail-tools from the test file, silence Org state-change messages in the test fixture, and update the copyright year.

Commit
318f4f1351e9128bd3c036a9595be2d3d9769fcb
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
skills/rail/rail-tests.el
index 6766255d..5f1ddd1b 100644..100644
@@ -15,6 +15,7 @@
15 15
16 16 (require 'ert)
17 17 (require 'json)
18 Added: (require 'rail-tools)
18 19
19 20 ;;; Fixtures
20 21
@@ -53,13 +54,15 @@
53 54
54 55 (defmacro rail-tests--with-stream (root &rest body)
55 56 "Create a temporary project directory, bind ROOT to it, then run BODY.
56 Removed: Delete the directory and its buffers after BODY."
57 Added: Delete the directory and its buffers after BODY. Silence the messages
58 Added: that Org prints on each state change, so the ERT output stays readable."
57 59 (declare (indent 1))
58 60 `(let* ((,root (file-name-as-directory (make-temp-file "rail-test" t)))
59 61 (file (expand-file-name rail-stream-file-name ,root))
60 62 (enable-local-variables :all)
61 63 (org-id-track-globally nil)
62 Removed: (create-lockfiles nil))
64 Added: (create-lockfiles nil)
65 Added: (inhibit-message t))
63 66 (unwind-protect
64 67 (progn
65 68 (with-temp-file file (insert rail-tests--preamble))
@@ -423,6 +426,37 @@
423 426 (should (equal (mapcar #'car axes) '(kind scope impact)))
424 427 (should (equal (append (alist-get 'impact axes) nil) '("minor" "major")))
425 428 (should (equal (append (alist-get 'scope axes) nil) '("core" "app" "web" "ui"))))))
429 Added:
430 Added: (ert-deftest rail-test-inspect-collects-bare-tags-under-misc ()
431 Added: "Tags outside a named group land under the `misc' axis, and still count.
432 Added: Org parses `{ }' groups and `[ ]' groups into different markers. A
433 Added: group with no group tag has no name. The tool must keep every declared
434 Added: tag in the vocabulary, so an undeclared tag is still refused."
435 Added: (let ((rail-tests--preamble
436 Added: (concat "#+TITLE: Mixed vocabulary\n"
437 Added: "#+TODO: TODO | DONE\n"
438 Added: "#+TAGS: [ Kind : feat fix ]\n"
439 Added: "#+TAGS: { Scope : core app }\n"
440 Added: "#+TAGS: { alpha beta }\n"
441 Added: "#+TAGS: loose other\n")))
442 Added: (rail-tests--with-stream root
443 Added: (let* ((result (rail-tests--call #'rail-tools--inspect-handler
444 Added: (list (cons 'root root))))
445 Added: (axes (alist-get 'tags result)))
446 Added: (should (equal (mapcar #'car axes) '(kind scope misc)))
447 Added: (should (equal (append (alist-get 'scope axes) nil) '("core" "app")))
448 Added: (should (equal (append (alist-get 'misc axes) nil)
449 Added: '("alpha" "beta" "loose" "other"))))
450 Added: ;; A bare tag is part of the vocabulary.
451 Added: (should (stringp (rail-tests--capture root "Bare tag" ["loose"])))
452 Added: ;; An undeclared tag is still refused, because the file does declare
453 Added: ;; a vocabulary.
454 Added: (let ((result (rail-tests--decode
455 Added: (rail-tools--capture-handler
456 Added: (list (cons 'root root) (cons 'title "Bad tag")
457 Added: (cons 'tags ["undeclared"])
458 Added: (cons 'body "Body."))))))
459 Added: (should (stringp (alist-get 'error result)))))))
426 460
427 461 (ert-deftest rail-test-a-file-without-tags-accepts-any-tag ()
428 462 "A file that declares no #+TAGS: vocabulary accepts any tag.
skills/rail/rail-tools.el
index 6921d45e..e2b2a1bc 100644..100644
@@ -1,6 +1,6 @@
1 1 ;;; rail-tools.el --- RAIL MCP tools for RAIL.org -*- lexical-binding: t; -*-
2 2
3 Removed: ;; Copyright (C) 2025
3 Added: ;; Copyright (C) 2026
4 4
5 5 ;; This file is NOT part of GNU Emacs.
6 6
@@ -67,6 +67,12 @@
67 67 (defconst rail-mcp-available (require 'mcp-server-tools nil t)
68 68 "Non-nil when the Emacs MCP tool framework is available.")
69 69
70 Added: ;; Tell the byte compiler about the two framework entry points. The
71 Added: ;; stubs below define them at run time when the framework is absent, but
72 Added: ;; the compiler does not see inside the `unless' form.
73 Added: (declare-function make-mcp-server-tool "mcp-server-tools")
74 Added: (declare-function mcp-server-register-tool "mcp-server-tools")
75 Added:
70 76 (unless rail-mcp-available
71 77 ;; Define plain functions, never a struct. A stub struct would clobber the
72 78 ;; real slot layout if the framework loads later in the same session.
@@ -286,27 +292,42 @@
286 292
287 293 (defun rail-tools--tag-axes ()
288 294 "Return the tag vocabulary of the current buffer, grouped by axis.
289 Removed: Read the group tags that Org parsed from the `#+TAGS:' lines into
295 Added: Read the tags that Org parsed from the `#+TAGS:' lines into
290 296 `org-current-tag-alist'. Return an alist that maps each axis symbol to
291 Removed: its list of tag strings. Return nil when the file declares no axis, and
292 Removed: then the file accepts any tag."
293 Removed: (let ((axes '()) (current nil))
297 Added: its list of tag strings.
298 Added:
299 Added: A named group, such as `[ Kind : feat fix ]' or `{ Kind : feat fix }',
300 Added: gives one axis named after its group tag, in lower case. Tags outside
301 Added: any group, and tags in a group with no group tag, land under the axis
302 Added: `misc'. Return nil when the file declares no tag at all, and then the
303 Added: file accepts any tag."
304 Added: (let ((axes '()) (misc '())
305 Added: (in-group nil) (named nil) (current nil))
294 306 (dolist (entry org-current-tag-alist)
295 307 (pcase entry
296 Removed: (`(:startgrouptag) (setq current nil))
297 Removed: (`(:endgrouptag)
298 Removed: (when current
308 Added: ((or `(:startgroup) `(:startgrouptag))
309 Added: (setq in-group t named nil current nil))
310 Added: (`(:grouptags)
311 Added: (setq named t))
312 Added: ((or `(:endgroup) `(:endgrouptag))
313 Added: (cond
314 Added: ((and named current)
315 Added: ;; The first tag in a named group is the axis name.
299 316 (push (cons (intern (downcase (car current)))
300 317 (nreverse (cdr current)))
301 318 axes))
302 Removed: (setq current nil))
303 Removed: (`(:grouptags))
319 Added: (current
320 Added: (setq misc (append (reverse current) misc))))
321 Added: (setq in-group nil named nil current nil))
304 322 (`(,(and tag (pred stringp)) . ,_)
305 Removed: (if current
306 Removed: (setcdr current (cons tag (cdr current)))
307 Removed: ;; The first tag in a group is the axis name.
308 Removed: (setq current (cons tag '()))))))
309 Removed: (nreverse axes)))
323 Added: (cond
324 Added: ((not in-group) (push tag misc))
325 Added: ((null current) (setq current (list tag)))
326 Added: (t (push tag (cdr current)))))))
327 Added: (setq axes (nreverse axes))
328 Added: (if misc
329 Added: (append axes (list (cons 'misc (nreverse misc))))
330 Added: axes)))
310 331
311 332 (defun rail-tools--all-tags ()
312 333 "Return every tag the current buffer declares, as one flat list.