aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 01:01:38 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 01:01:38 -0400
commit25e74fcb57ae467a39ecabe41568a986db08ebe4 (patch)
tree38cf1aa6201e4d2fc59078f088bdda3e60d145ed
parent6252ec01350bd211e043bb84b31498cc00a11046 (diff)
downloaddotemacs-25e74fcb57ae467a39ecabe41568a986db08ebe4.tar.gz
dotemacs-25e74fcb57ae467a39ecabe41568a986db08ebe4.zip
feat(org): resolve org-id links into project spec docs
The docs-lifecycle convention gives every formal spec under a project's docs/specs/ an :ID: and links cross-project with [[id:...]], but org-id-locations only indexes agenda files and visited files, so a fresh spec's id never resolved on click. org-spec-links.el enumerates every project's docs/specs/*.org into org-id-extra-files once org-id loads (a literal file list; org-id doesn't glob), and cj/org-id-refresh-spec-locations re-scans and updates org-id-locations for immediacy after new specs land. Verified live against a known cross-project spec id.
-rw-r--r--init.el1
-rw-r--r--modules/org-spec-links.el80
-rw-r--r--tests/test-init-module-headers.el1
-rw-r--r--tests/test-org-spec-links.el79
4 files changed, 161 insertions, 0 deletions
diff --git a/init.el b/init.el
index c93a8012..591de3c8 100644
--- a/init.el
+++ b/init.el
@@ -140,6 +140,7 @@
(require 'org-refile-config) ;; refile org-branches
(require 'org-roam-config) ;; personal knowledge management in org mode
+(require 'org-spec-links) ;; resolve [[id:]] links into project docs/specs
(require 'org-webclipper) ;; "instapaper" to org-roam workflow
(require 'org-noter-config)
diff --git a/modules/org-spec-links.el b/modules/org-spec-links.el
new file mode 100644
index 00000000..369a4c23
--- /dev/null
+++ b/modules/org-spec-links.el
@@ -0,0 +1,80 @@
+;;; org-spec-links.el --- Resolve org-id links into project spec docs -*- lexical-binding: t -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D/S.
+;; Load shape: eager (cheap: defuns + one after-load registration).
+;; Eager reason: none beyond the after-load hook; the scan itself runs
+;; only when org-id loads.
+;; Top-level side effects: one with-eval-after-load (org-id).
+;; Runtime requires: none (org-id is configured after it loads).
+;; Direct test load: yes.
+;;
+;; The docs-lifecycle convention (rulesets, 2026-07-01) gives every
+;; formal spec under a project's docs/specs/ an :ID: UUID, and
+;; cross-project links use [[id:...]]. org-id-locations only indexes
+;; agenda files and files Emacs has visited, so a fresh spec's ID won't
+;; resolve on click. This module enumerates every project's
+;; docs/specs/*.org into `org-id-extra-files' (a literal file list --
+;; org-id doesn't glob) so org-id's own scans can find them, and
+;; provides `cj/org-id-refresh-spec-locations' to re-scan after new
+;; specs land and teach `org-id-locations' immediately.
+;;
+;; Projects are directories carrying .ai/protocols.org under ~/code/,
+;; ~/projects/, and ~/.emacs.d -- the same discovery the `ai' launcher
+;; and inbox-send use.
+
+;;; Code:
+
+(defun cj/--org-spec-project-base-dirs ()
+ "Return the base directories scanned for projects."
+ (list (expand-file-name "code/" "~")
+ (expand-file-name "projects/" "~")))
+
+(defun cj/--org-spec-project-roots (&optional base-dirs)
+ "Return project roots: dirs carrying .ai/protocols.org.
+Scans one level under each of BASE-DIRS (default
+`cj/--org-spec-project-base-dirs'), plus `user-emacs-directory'."
+ (let ((candidates
+ (append
+ (mapcan (lambda (base)
+ (when (file-directory-p base)
+ (directory-files base t "\\`[^.]" t)))
+ (or base-dirs (cj/--org-spec-project-base-dirs)))
+ (unless base-dirs (list user-emacs-directory)))))
+ (seq-filter (lambda (dir)
+ (and (file-directory-p dir)
+ (file-exists-p (expand-file-name ".ai/protocols.org" dir))))
+ candidates)))
+
+(defun cj/--org-spec-files (&optional base-dirs)
+ "Return every project's docs/specs/*.org as absolute file names.
+BASE-DIRS overrides the default project discovery (for tests)."
+ (mapcan (lambda (root)
+ (let ((specs (expand-file-name "docs/specs/" root)))
+ (when (file-directory-p specs)
+ (directory-files specs t "\\.org\\'" t))))
+ (cj/--org-spec-project-roots base-dirs)))
+
+(defun cj/org-id-refresh-spec-locations ()
+ "Point `org-id-extra-files' at every project's spec docs and rescan.
+Run after new specs land (a spec-sort pass, a new project) so their
+:ID:s resolve. The rescan also walks agenda files, so it can take a
+few seconds; the `org-id-extra-files' assignment alone is instant."
+ (interactive)
+ (let ((files (cj/--org-spec-files)))
+ (setq org-id-extra-files files)
+ (when (and files (fboundp 'org-id-update-id-locations))
+ (org-id-update-id-locations files))
+ (message "org-id: %d spec file(s) registered" (length files))))
+
+;; Teach org-id the spec files as soon as it loads: the extra-files list
+;; is enough for org-id's own fallback scans; the full refresh command
+;; is for immediacy after new specs land.
+(with-eval-after-load 'org-id
+ (setq org-id-extra-files (cj/--org-spec-files)))
+
+(provide 'org-spec-links)
+;;; org-spec-links.el ends here
diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el
index fc956084..f89d6624 100644
--- a/tests/test-init-module-headers.el
+++ b/tests/test-init-module-headers.el
@@ -93,6 +93,7 @@
"org-refile-config"
"org-reveal-config"
"org-roam-config"
+ "org-spec-links"
"org-webclipper"
"hugo-config"
;; Batch 8 — Domain / integration / optional modules (Layer 2-4)
diff --git a/tests/test-org-spec-links.el b/tests/test-org-spec-links.el
new file mode 100644
index 00000000..6359689f
--- /dev/null
+++ b/tests/test-org-spec-links.el
@@ -0,0 +1,79 @@
+;;; test-org-spec-links.el --- docs/specs org-id resolution -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the docs/specs spec-file scanner behind org-id link
+;; resolution (org-spec-links.el). Projects are directories carrying
+;; .ai/protocols.org; each contributes its docs/specs/*.org files. The
+;; scanner takes explicit base dirs so the tests run against a sandbox.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(require 'org-spec-links)
+
+;; org-id may never load in batch; declare its var special so the
+;; let-binding below is dynamic, not a silent lexical shadow.
+(defvar org-id-extra-files)
+
+(defun test-org-spec-links--make-sandbox ()
+ "Build a sandbox: two projects, one with specs, one without; one non-project."
+ (let ((base (make-temp-file "spec-links-" t)))
+ ;; p1: a project with two spec files
+ (make-directory (expand-file-name "p1/.ai" base) t)
+ (write-region "" nil (expand-file-name "p1/.ai/protocols.org" base))
+ (make-directory (expand-file-name "p1/docs/specs" base) t)
+ (write-region "" nil (expand-file-name "p1/docs/specs/a-spec.org" base))
+ (write-region "" nil (expand-file-name "p1/docs/specs/b-spec.org" base))
+ ;; p2: a project with no docs/specs
+ (make-directory (expand-file-name "p2/.ai" base) t)
+ (write-region "" nil (expand-file-name "p2/.ai/protocols.org" base))
+ ;; p3: not a project (no protocols.org) but has docs/specs
+ (make-directory (expand-file-name "p3/docs/specs" base) t)
+ (write-region "" nil (expand-file-name "p3/docs/specs/c-spec.org" base))
+ base))
+
+(ert-deftest test-org-spec-links-scanner-finds-project-specs ()
+ "Normal: spec files from protocols-carrying projects only, absolute paths."
+ (let ((base (test-org-spec-links--make-sandbox)))
+ (unwind-protect
+ (let ((files (cj/--org-spec-files (list base))))
+ (should (= (length files) 2))
+ (should (cl-every #'file-name-absolute-p files))
+ (should (seq-find (lambda (f) (string-suffix-p "p1/docs/specs/a-spec.org" f)) files))
+ (should (seq-find (lambda (f) (string-suffix-p "p1/docs/specs/b-spec.org" f)) files))
+ (should-not (seq-find (lambda (f) (string-match-p "p3" f)) files)))
+ (delete-directory base t))))
+
+(ert-deftest test-org-spec-links-scanner-empty-base ()
+ "Boundary: a base dir with no projects yields nil."
+ (let ((base (make-temp-file "spec-links-empty-" t)))
+ (unwind-protect
+ (should-not (cj/--org-spec-files (list base)))
+ (delete-directory base t))))
+
+(ert-deftest test-org-spec-links-scanner-missing-base ()
+ "Error: a non-existent base dir is skipped without signaling."
+ (should-not (cj/--org-spec-files '("/nonexistent/base/dir"))))
+
+(ert-deftest test-org-spec-links-refresh-sets-extra-files ()
+ "Normal: the refresh command points org-id-extra-files at the scan result."
+ (let* ((base (test-org-spec-links--make-sandbox))
+ (org-id-extra-files nil)
+ ;; the default scan appends user-emacs-directory; point it into
+ ;; the sandbox so the real config's specs don't leak in
+ (user-emacs-directory (expand-file-name "p2/" base)))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--org-spec-project-base-dirs)
+ (lambda () (list base)))
+ ;; org-id may be absent in batch; the update step is mocked.
+ ((symbol-function 'org-id-update-id-locations)
+ (lambda (&rest _) nil)))
+ (cj/org-id-refresh-spec-locations)
+ (should (= (length org-id-extra-files) 2)))
+ (delete-directory base t))))
+
+(provide 'test-org-spec-links)
+;;; test-org-spec-links.el ends here