diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-02 21:31:37 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-02 21:31:37 -0500 |
| commit | ac693a6b7fa7abe88f7778f8e793d5ddfd32f24e (patch) | |
| tree | ac0e8babf829ffd3ab64bb3b2bb5e9498d1f2f13 /claude-templates | |
| parent | 291103803495cd1937244dc7c993faaaf00023ab (diff) | |
| download | rulesets-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.el | 85 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-lint-org.el | 105 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/task-audit.org | 2 |
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. |
