aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-02 21:31:37 -0500
committerCraig Jennings <c@cjennings.net>2026-06-02 21:31:37 -0500
commitac693a6b7fa7abe88f7778f8e793d5ddfd32f24e (patch)
treeac0e8babf829ffd3ab64bb3b2bb5e9498d1f2f13 /claude-templates
parent291103803495cd1937244dc7c993faaaf00023ab (diff)
downloadrulesets-ac693a6b7fa7abe88f7778f8e793d5ddfd32f24e.tar.gz
rulesets-ac693a6b7fa7abe88f7778f8e793d5ddfd32f24e.zip
feat(lint-org): reconcile follow-ups on write instead of appending
Every run appended a fresh dated "lint-org follow-ups" section with line-number-keyed entries, so the follow-ups file grew an unbounded pile of near-duplicate sections, kept entries whose finding had since resolved, and broke whenever the target file's line numbers shifted. Running an audit against a large todo.org surfaced exactly that drift: dead-link flags pointing at docs that now exist, and three stacked dated runs for one file. Now lint-org rewrites the current file's section from the current run. Findings that no longer reproduce simply are not re-emitted, re-runs dedupe to one section, and entries key on checker plus message with the line as a trailing annotation, so a finding survives line shifts as the same entry. Other files' sections are left intact, and the strip step tolerates the old dated-header shape so existing follow-ups files migrate on first run. This changes the follow-ups file from an append-only log to the current outstanding findings per file. task-audit's Phase C link-hygiene step now also reaps a matching dead-link entry when it fixes or verifies the link, scoped strictly to dead-link entries, so the audit and the follow-ups file stop drifting between lint runs. Five follow-ups tests cover record-by-content, dedupe across runs, drop-on-resolve, and preserve-other-files. Mirrors synced.
Diffstat (limited to 'claude-templates')
-rw-r--r--claude-templates/.ai/scripts/lint-org.el85
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el105
-rw-r--r--claude-templates/.ai/workflows/task-audit.org2
3 files changed, 163 insertions, 29 deletions
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 2e97db0..85886af 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -322,27 +322,80 @@ left unmodified and mechanical entries are recorded with :preview t."
;;; ---------------------------------------------------------------------------
;;; Reporting
+(defun lo--followups-section (file judgments)
+ "Org section text for FILE's JUDGMENTS, keyed by checker + message.
+Empty string when there are no judgments. The line number is a trailing
+annotation, not the entry's identity, so a finding that shifts lines after an
+edit is still recognized as the same finding."
+ (if (null judgments)
+ ""
+ (concat
+ (format "* lint-org follow-ups — %s (%s)\n"
+ (file-name-nondirectory file)
+ (format-time-string "%Y-%m-%d"))
+ (mapconcat
+ (lambda (i)
+ (format "** TODO %s — %s (line %d)\n"
+ (plist-get i :checker)
+ (plist-get i :msg)
+ (plist-get i :line)))
+ judgments
+ ""))))
+
+(defun lo--strip-followups-section (content file)
+ "Return CONTENT with FILE's existing follow-ups section(s) removed.
+A section runs from its `* lint-org follow-ups — FILE ...' header to the next
+top-level `* ' heading or end of buffer. Matched on the file name, so it
+survives line-number churn in the target file, and tolerates the older
+`* DATE lint-org follow-ups — FILE' header shape so legacy runs migrate cleanly."
+ (with-temp-buffer
+ (insert content)
+ (let ((header-re
+ (format "^\\* \\(?:[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} \\)?lint-org follow-ups — %s "
+ (regexp-quote (file-name-nondirectory file)))))
+ (while (progn (goto-char (point-min))
+ (re-search-forward header-re nil t))
+ (let ((start (line-beginning-position))
+ (end (if (re-search-forward "^\\* " nil t)
+ (line-beginning-position)
+ (point-max))))
+ (delete-region start end))))
+ (buffer-string)))
+
+(defun lo--reconciled-followups (existing file judgments)
+ "Reconcile EXISTING follow-ups content for FILE against the current JUDGMENTS.
+FILE's prior section is replaced by the current findings (dropped entirely when
+nothing reproduces); other files' sections are left intact. This is what makes
+re-runs dedupe and resolved findings disappear instead of piling up."
+ (let ((stripped (string-trim (lo--strip-followups-section existing file)))
+ (section (string-trim (lo--followups-section file judgments))))
+ (cond
+ ((and (string= stripped "") (string= section "")) "")
+ ((string= section "") (concat stripped "\n"))
+ ((string= stripped "") (concat section "\n"))
+ (t (concat stripped "\n\n" section "\n")))))
+
(defun lo--append-followups ()
- "Append any judgment items from the current run to `lo-followups-file' as a
-dated org section. No-op when the file path is unset or there are no
-judgment items."
+ "Reconcile the current run's judgment items into `lo-followups-file'.
+Rewrites FILE's section from the current findings: entries are keyed by content
+(checker + message) rather than line number, findings that no longer reproduce
+are dropped, and re-runs dedupe instead of appending a fresh dated section.
+No-op when the file path is unset, or when there are no judgments and no file to
+reconcile."
(when lo-followups-file
(let ((judgments (cl-remove-if-not
(lambda (i) (eq (plist-get i :kind) 'judgment))
(reverse lo-issues))))
- (when judgments
- (let ((dir (file-name-directory (expand-file-name lo-followups-file))))
- (when dir (make-directory dir t)))
- (with-temp-buffer
- (insert (format "\n* %s lint-org follow-ups — %s\n"
- (format-time-string "%Y-%m-%d")
- (file-name-nondirectory lo-current-file)))
- (dolist (i judgments)
- (insert (format "** TODO line %d — %s — %s\n"
- (plist-get i :line)
- (plist-get i :checker)
- (plist-get i :msg))))
- (append-to-file (point-min) (point-max) lo-followups-file))))))
+ (when (or judgments (file-exists-p lo-followups-file))
+ (let ((existing (if (file-exists-p lo-followups-file)
+ (with-temp-buffer
+ (insert-file-contents lo-followups-file)
+ (buffer-string))
+ ""))
+ (dir (file-name-directory (expand-file-name lo-followups-file))))
+ (when dir (make-directory dir t))
+ (with-temp-file lo-followups-file
+ (insert (lo--reconciled-followups existing lo-current-file judgments))))))))
(defun lo-emit-report ()
"Print the per-file summary line plus each issue as a readable plist.
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 416f4f6..60deb8c 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -428,7 +428,9 @@ suspicious-language judgment."
;;; ---------------------------------------------------------------------------
;;; Follow-ups file behavior
-(ert-deftest lo-followups-file-appends-judgments ()
+(ert-deftest lo-followups-records-judgments-by-content ()
+ "Each judgment is recorded under a per-file section, keyed by checker +
+message with the line as a trailing annotation rather than the entry's identity."
(let ((followups (make-temp-file "lo-followups-" nil ".org"))
(file (make-temp-file "lo-test-fup-" nil ".org")))
(unwind-protect
@@ -442,17 +444,96 @@ suspicious-language judgment."
(let ((content (with-temp-buffer
(insert-file-contents followups)
(buffer-string))))
- ;; Dated section header.
- (should (string-match-p
- (format "^\\* %s lint-org follow-ups"
- (format-time-string "%Y-%m-%d"))
- content))
- ;; Each judgment is a TODO line referencing checker + line number.
- (should (string-match-p "TODO line [0-9]+ — link-to-local-file" content))
- (should (string-match-p "TODO line [0-9]+ — invalid-fuzzy-link" content))
- (should (string-match-p
- "TODO line [0-9]+ — suspicious-language-in-src-block"
- content))))
+ ;; Per-file section header naming the file.
+ (should (string-match-p "^\\* lint-org follow-ups — " content))
+ ;; Entries lead with the checker (the content key); line is annotation.
+ (should (lo-test--has content "TODO link-to-local-file — "))
+ (should (lo-test--has content "TODO invalid-fuzzy-link — "))
+ (should (lo-test--has content "TODO suspicious-language-in-src-block — "))
+ (should (string-match-p "(line [0-9]+)" content))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-dedupes-across-runs ()
+ "Running twice on the same file reconciles to one section, not two appended
+sections."
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-ded-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--mixed))
+ (with-temp-file followups (insert ""))
+ (dotimes (_ 2)
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file))
+ (let ((content (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string)))
+ (n 0) (start 0))
+ (while (string-match "^\\* lint-org follow-ups — " content start)
+ (setq n (1+ n) start (match-end 0)))
+ (should (= n 1))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-drops-resolved-finding ()
+ "A finding that no longer reproduces (its source was fixed) is dropped from the
+followups file on the next run."
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-drop-" nil ".org")))
+ (unwind-protect
+ (progn
+ ;; First run: a broken file link produces a judgment.
+ (with-temp-file file
+ (insert "#+TITLE: t\n* H\n[[file:does-not-exist-zzz.org][broken]]\n"))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (should (lo-test--has
+ (with-temp-buffer (insert-file-contents followups) (buffer-string))
+ "link-to-local-file"))
+ ;; Fix the source, re-run: the finding no longer reproduces.
+ (with-temp-file file (insert "#+TITLE: t\n* H\nclean now\n"))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (let ((content (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string))))
+ (should-not (lo-test--has content "link-to-local-file"))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-preserves-other-files-sections ()
+ "Reconciling one file's section leaves other files' sections intact."
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-other-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file followups
+ (insert "* lint-org follow-ups — other-file.org (2026-01-01)\n"
+ "** TODO some-checker — a prior finding (line 5)\n"))
+ (with-temp-file file (insert lo-test--mixed))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (let ((content (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string))))
+ ;; The unrelated file's section survives.
+ (should (lo-test--has content "other-file.org"))
+ (should (lo-test--has content "a prior finding"))
+ ;; This file's section was added alongside it.
+ (should (lo-test--has content "link-to-local-file"))))
(lo-test--drop-buffer file)
(when (file-exists-p file) (delete-file file))
(when (file-exists-p followups) (delete-file followups)))))
diff --git a/claude-templates/.ai/workflows/task-audit.org b/claude-templates/.ai/workflows/task-audit.org
index 5269563..2b0ac29 100644
--- a/claude-templates/.ai/workflows/task-audit.org
+++ b/claude-templates/.ai/workflows/task-audit.org
@@ -71,7 +71,7 @@ For every STALE task, edit it in the main thread:
- Add a dated reconciliation note (=*** YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <what changed>=) or update the offending line in place.
- Mark statuses that moved; rewrite "waiting on X" lines whose X resolved.
-- Fix dead/renamed =file:= links.
+- Fix dead/renamed =file:= links. When you fix or verify a link that has a matching dead-link entry in the project's lint-followups file, reap that entry in the same edit so the two artifacts don't drift. Scope this strictly to dead-link entries. Do not pull general lint cleanup into the audit, which mixes two concerns and slows it.
- *Consolidate duplicates* — when several tasks track the same thing, fold them into one home and delete the duplicates (per the user's call on which is canonical).
- *Ensure priority is set per the project scheme.* The top of the project's =todo.org= should carry the priority legend (=[#A]= through =[#D]=). Every task should carry an explicit priority cookie. If a cookie is missing, or no longer matches the reconciled facts, assign the right level per the legend. If the level is unambiguous from the body, do it autonomously; if it's a judgment call (especially the [#A] / [#B] line for important-but-not-urgent work), flag NEEDS-USER. Also enforce the [#A]-discipline rule from the legend — an [#A] task without a =SCHEDULED:= or =DEADLINE:= line is mis-graded and is either down-graded to [#B] (when reconciled facts say "important but not urgent") or surfaced as NEEDS-USER for the user to date.
- *Ensure a type tag is set.* Every task carries one type tag from the project's tag legend (typically =:feature:= / =:chore:= / =:spec:= / =:bug:=). If missing or wrong, assign or correct it from the body when the type is unambiguous. If two tags fit (a refactor that also fixes a bug; a spec that's also a chore), flag NEEDS-USER rather than picking one silently.