peaks-loop 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +2 -2
  5. package/dist/cli/commands/code-mode-gate-should-pause-command.js +1 -1
  6. package/dist/cli/commands/comments-commands.d.ts +14 -0
  7. package/dist/cli/commands/comments-commands.js +96 -0
  8. package/dist/cli/commands/core/memory-command.js +10 -0
  9. package/dist/cli/commands/core/skill-command.js +1 -1
  10. package/dist/cli/commands/core/standards-command.js +1 -1
  11. package/dist/cli/commands/ecc-commands.d.ts +17 -22
  12. package/dist/cli/commands/ecc-commands.js +38 -26
  13. package/dist/cli/commands/prd-commands.js +8 -2
  14. package/dist/cli/program.js +4 -9
  15. package/dist/services/code/mode-gate-types.d.ts +1 -1
  16. package/dist/services/code/mode-gate-types.js +0 -1
  17. package/dist/services/code/mode-gate.js +1 -9
  18. package/dist/services/code/user-touchpoint-classifier.js +0 -7
  19. package/dist/services/code-review/ecc-bridge.d.ts +6 -6
  20. package/dist/services/comments/citation-rules.d.ts +154 -0
  21. package/dist/services/comments/citation-rules.js +241 -0
  22. package/dist/services/comments/comment-audit.d.ts +57 -0
  23. package/dist/services/comments/comment-audit.js +100 -0
  24. package/dist/services/comments/comment-citations.d.ts +60 -0
  25. package/dist/services/comments/comment-citations.js +186 -0
  26. package/dist/services/comments/comment-hygiene.d.ts +79 -0
  27. package/dist/services/comments/comment-hygiene.js +133 -0
  28. package/dist/services/comments/comment-prune.d.ts +88 -0
  29. package/dist/services/comments/comment-prune.js +148 -0
  30. package/dist/services/comments/prune-apply.d.ts +60 -0
  31. package/dist/services/comments/prune-apply.js +150 -0
  32. package/dist/services/comments/repo-path-probe.d.ts +43 -0
  33. package/dist/services/comments/repo-path-probe.js +78 -0
  34. package/dist/services/log/retention.d.ts +0 -16
  35. package/dist/services/log/retention.js +0 -17
  36. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  37. package/dist/services/memory/project-memory-service/index.js +1 -1
  38. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +19 -7
  39. package/dist/services/memory/project-memory-service/store/atomic-write.js +120 -26
  40. package/dist/services/prd/handoff-frontmatter.js +61 -0
  41. package/dist/services/prd/handoff-gate-evidence.js +14 -10
  42. package/dist/services/prd/handoff-service.d.ts +11 -1
  43. package/dist/services/prd/handoff-service.js +11 -1
  44. package/dist/services/prd/handoff-types.d.ts +33 -1
  45. package/dist/services/recommendations/installed-capability-detector.d.ts +5 -5
  46. package/dist/services/recommendations/installed-capability-detector.js +11 -10
  47. package/dist/services/scan/archetype-detection.d.ts +37 -0
  48. package/dist/services/scan/archetype-detection.js +175 -2
  49. package/dist/services/scan/archetype-service.js +36 -22
  50. package/dist/services/scan/scan-types.d.ts +9 -0
  51. package/dist/services/workspace/generated-artifacts-stamp.d.ts +2 -2
  52. package/dist/services/workspace/generated-artifacts-stamp.js +2 -2
  53. package/dist/services/workspace/workspace-service.js +1 -1
  54. package/package.json +5 -5
  55. package/scripts/install-skills.mjs +0 -177
  56. package/skills/bee/peaks-qa/references/reading-handoff-frontmatter.md +3 -1
  57. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +1 -1
  58. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +3 -2
  59. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +29 -7
  60. package/skills/peaks-code/references/frontend-only-mode.md +2 -2
  61. package/skills/peaks-code/references/startup-sequence.md +0 -4
  62. package/dist/cli/commands/upgrade-commands.d.ts +0 -25
  63. package/dist/cli/commands/upgrade-commands.js +0 -154
  64. package/dist/services/upgrade/1x-detector-service.d.ts +0 -7
  65. package/dist/services/upgrade/1x-detector-service.js +0 -96
  66. package/dist/services/upgrade/gitignore-migrate-service.d.ts +0 -56
  67. package/dist/services/upgrade/gitignore-migrate-service.js +0 -170
  68. package/dist/services/upgrade/upgrade-service.d.ts +0 -81
  69. package/dist/services/upgrade/upgrade-service.js +0 -428
  70. package/skills/peaks-code/references/step-0-55-1x-detection.md +0 -83
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Comment-only views of a source file, and whether a citation it makes holds.
3
+ *
4
+ * The shape and candidate rules live in `citation-rules.ts`, shared with the
5
+ * repository's citation guard; this module holds the two things that rule needs in
6
+ * order to be applied: the comment lines of a file, and the probes that answer
7
+ * "does this path resolve here".
8
+ *
9
+ * Deliberately NOT `ts.getLeadingCommentRanges`:
10
+ * `src/services/qa/bdd-test-style-verifier.ts` records that the API returns zero
11
+ * ranges for comment-only blocks, which would silently read a header comment as no
12
+ * comment at all. Line scanning preserves both order and line numbers, which the
13
+ * worklist and the prune both need.
14
+ */
15
+ import { existsSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { LINE_SUFFIX, PATH_SHAPED, REPO_ANCHORS, SESSION_WORKSPACE_DIRS, SYNTHETIC_SEGMENT } from './citation-rules.js';
18
+ /** Repo-relative paths are POSIX even on a Windows host, or every lookup below
19
+ * compares a `src\a.ts` against the forward-slash keys and the answer is always "no". */
20
+ function toPosix(path) {
21
+ return path.replace(/\\/g, '/');
22
+ }
23
+ /**
24
+ * A `//` in a line of code is a trailing comment unless the text before it is
25
+ * inside a string literal or a URL scheme. The second clause matters because
26
+ * `"https://…"` is a line every repository writes, and reading it as a comment
27
+ * invents a line the author never meant.
28
+ */
29
+ function trailingComment(raw) {
30
+ const at = raw.indexOf('//');
31
+ if (at <= 0)
32
+ return '';
33
+ const before = raw.slice(0, at);
34
+ if (before.endsWith(':'))
35
+ return '';
36
+ if ((before.match(/["']/g) ?? []).length % 2 !== 0)
37
+ return '';
38
+ return raw.slice(at);
39
+ }
40
+ /**
41
+ * The comment lines of a file: full-line comments, plus the trailing comment of a
42
+ * code line. Non-comment lines are omitted but their numbers survive, so a report
43
+ * can point at the code a comment is attached to.
44
+ */
45
+ export function commentLines(source) {
46
+ const out = [];
47
+ source.split(/\r?\n/).forEach((raw, index) => {
48
+ const line = index + 1;
49
+ const trimmed = raw.trim();
50
+ if (trimmed.startsWith('//') ||
51
+ trimmed.startsWith('/*') ||
52
+ trimmed.startsWith('/**') ||
53
+ trimmed.startsWith('*')) {
54
+ out.push({ line, text: raw });
55
+ return;
56
+ }
57
+ const comment = trailingComment(raw);
58
+ if (comment.length > 0) {
59
+ out.push({ line, text: comment });
60
+ }
61
+ });
62
+ return out;
63
+ }
64
+ /**
65
+ * The path-shaped backtick spans in a line, in order, with their bounds kept.
66
+ *
67
+ * The bounds are not decoration: two of the candidate rules read the text OUTSIDE
68
+ * the span — an illustration cue sits immediately before it, a `<…>` fill-in wraps
69
+ * it — so a caller that discards positions cannot apply them.
70
+ */
71
+ export function citations(text) {
72
+ const found = [];
73
+ for (const match of text.matchAll(/`([^`\s]+)`/g)) {
74
+ const span = match[1] ?? '';
75
+ const from = match.index ?? 0;
76
+ if (PATH_SHAPED.test(span)) {
77
+ found.push({ span, from, to: from + span.length + 2 });
78
+ }
79
+ }
80
+ return found;
81
+ }
82
+ /** Every path-shaped span in a line, without its bounds. */
83
+ export function citedPaths(text) {
84
+ return citations(text).map((citation) => citation.span);
85
+ }
86
+ /**
87
+ * Is this span a module specifier rather than a repo path? `peaks-loop-shared/result`
88
+ * in prose names an import, and no rule about files applies to it. Decided through
89
+ * the dependency tree instead of an allowlist: the first segment is checked against
90
+ * installed packages, so a new dependency needs no edit here. An allowlist of
91
+ * today's package names would turn every future one into a false finding — and a
92
+ * rule that is wrong the moment the repository grows is a rule that gets widened
93
+ * until it stops meaning anything.
94
+ */
95
+ function isInstalledPackageName(head, second, installed) {
96
+ const name = head.startsWith('@') && second !== undefined ? `${head}/${second}` : head;
97
+ return installed(`node_modules/${name}`);
98
+ }
99
+ /**
100
+ * The two shapes a citation may clear without touching the tree: an installed
101
+ * package's module specifier, and a session-runtime dir abbreviated in prose.
102
+ */
103
+ function resolvesOutsideTheTree(stripped, exists, installed) {
104
+ const [head, second] = stripped.split('/');
105
+ if (head === undefined)
106
+ return false;
107
+ if (isInstalledPackageName(head, second, installed))
108
+ return true;
109
+ return SESSION_WORKSPACE_DIRS.has(head) && !REPO_ANCHORS.test(stripped);
110
+ }
111
+ /**
112
+ * The workspace package a citing file belongs to, or null at the repository root.
113
+ *
114
+ * In a pnpm workspace, `src/x.ts` written inside `packages/<name>/src/` names that
115
+ * package's own file, not a repository-root one: three comments in this repository cite
116
+ * `packages/peaks-loop-internal-runtime/src/status-protocol.ts` and
117
+ * `packages/peaks-loop-shared-channel/src/index.ts` by their package-relative spelling,
118
+ * and both files exist. Treating those as dead references would seed a ratchet with
119
+ * findings that refuse the next honest package-internal citation, so the package root is
120
+ * an origin — for a citing file that is itself inside a package, and never for a
121
+ * top-level one.
122
+ */
123
+ export function packageRootOf(citingFileRel) {
124
+ const segments = citingFileRel.split('/');
125
+ if (segments[0] !== 'packages' || segments[1] === undefined)
126
+ return null;
127
+ return `packages/${segments[1]}`;
128
+ }
129
+ /**
130
+ * Does the citation point at a file inside the citing file's OWN workspace package?
131
+ *
132
+ * `join` is POSIX-normalised on the way through, because a `packages\alpha/src/x.ts`
133
+ * key matches nothing on the host this repository is developed on.
134
+ */
135
+ function resolvesInOwnPackage(stripped, citingFileRel, exists) {
136
+ const pkg = packageRootOf(citingFileRel);
137
+ return pkg !== null && exists(toPosix(join(pkg, stripped)));
138
+ }
139
+ /**
140
+ * The two origins a repo-anchored citation may be satisfied at: the repository root,
141
+ * or the workspace package the citing file itself lives in.
142
+ *
143
+ * Extracted because `citationResolves` is at the complexity ceiling the shipped rule
144
+ * sets, and an inlined `||` there would have been the second branch this file cannot
145
+ * afford — a limit that forces a helper out is the limit working, not the limit being
146
+ * in the way.
147
+ */
148
+ function resolvesAtARootOrigin(stripped, citingFileRel, exists) {
149
+ return exists(stripped) || resolvesInOwnPackage(stripped, citingFileRel, exists);
150
+ }
151
+ /**
152
+ * Does this path-shaped citation resolve anywhere it is allowed to resolve?
153
+ *
154
+ * `installed` must be a filesystem-only probe, not the ignore-widened one:
155
+ * `node_modules` is itself in `.gitignore`, so an ignore-aware answer here is
156
+ * "yes, installed" for every first segment, and the whole dead-reference count
157
+ * collapses to zero while looking perfectly healthy. Measured, not theorised.
158
+ */
159
+ export function citationResolves(citation, citingFileRel, exists, installed = exists) {
160
+ const stripped = citation.replace(LINE_SUFFIX, '');
161
+ if (stripped.length === 0 || SYNTHETIC_SEGMENT.test(stripped))
162
+ return true;
163
+ if (resolvesOutsideTheTree(stripped, exists, installed))
164
+ return true;
165
+ if (resolvesAtARootOrigin(stripped, citingFileRel, exists))
166
+ return true;
167
+ // An anchored citation claims the repo root and nowhere else; anything that did
168
+ // not resolve above is dead.
169
+ if (REPO_ANCHORS.test(stripped))
170
+ return false;
171
+ // No repo anchor: read from the citing file's own directory and each ancestor
172
+ // below the repo root, which is where a sibling reference (a subdirectory path
173
+ // written by the file sitting next to it) actually points.
174
+ for (let dir = dirname(citingFileRel); dir.length > 0 && dir !== dirname(dir);) {
175
+ if (exists(toPosix(join(dir, stripped))))
176
+ return true;
177
+ dir = dirname(dir);
178
+ if (dir === '.')
179
+ return false;
180
+ }
181
+ return false;
182
+ }
183
+ /** The default existence probe, against a real working tree. */
184
+ export function treeExists(repoRoot) {
185
+ return (relPath) => existsSync(join(repoRoot, relPath));
186
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Which comments are still worth their bytes to the next reader.
3
+ *
4
+ * Two categories are reported as hygiene debt, and they fail in different
5
+ * directions, so they are kept separate rather than summed into one number:
6
+ *
7
+ * - `dead-reference` — the comment asserts that some file exists by naming a
8
+ * path, and that path resolves nowhere the citation is allowed to mean. This
9
+ * is checkable, and it is the class that has already cost this repository a
10
+ * wrong decision: the header of `src/services/prd/handoff-gate-evidence.ts`
11
+ * described a pre-existing "no producer" state in the same voice as the
12
+ * current one, a reader took it as fact, and the fix was chased into
13
+ * `dist/` instead of the source.
14
+ * - `narrative` — the comment is about the work rather than the code: slice
15
+ * and rid identifiers, `AC-3`, "F2 of `rid-…`", "this used to claim", "the
16
+ * test named above was deleted". Legitimately valuable in a commit message
17
+ * or `.peaks/docs/backlog.md`; in a source file it is re-read by every
18
+ * later agent, at their token cost, and it rots silently the moment the
19
+ * history it summarises moves.
20
+ *
21
+ * The narrative rule is a marker list, not a heuristic about length or
22
+ * sentence shape, precisely because a false positive here is worse than a false
23
+ * positive on `dead-reference`: a wrong `dead-reference` is visible in a diff as
24
+ * a removed path, while a wrong `narrative` deletes a reason someone wrote down
25
+ * on purpose. Every finding carries its matched marker for that reason — an
26
+ * unattributable finding is one nobody can review, and a rule nobody can review
27
+ * is a rule that will be widened until it stops mattering.
28
+ */
29
+ /** Why a comment line was flagged. */
30
+ export type CommentFindingKind = 'dead-reference' | 'narrative';
31
+ export type CommentFinding = {
32
+ readonly file: string;
33
+ readonly line: number;
34
+ readonly kind: CommentFindingKind;
35
+ /** The path that does not resolve, or the marker that matched. Never prose. */
36
+ readonly matched: string;
37
+ readonly text: string;
38
+ };
39
+ /**
40
+ * Development-process markers. Each entry is a named rule so a report can say
41
+ * WHICH one fired — the failure mode this whole feature is modelled on is a
42
+ * refusal that could not name itself (the content scan in
43
+ * `src/services/memory/project-memory-service/store/atomic-write.ts` answered a
44
+ * ten-pattern `||` chain with `matchedTerm: null`).
45
+ *
46
+ * Order is significant and is the whole attribution: one line reports its FIRST
47
+ * match, so a specific marker must precede the general one that also fits it.
48
+ * `F2 of \`rid-…\`` is a finding reference, not a bare rid, and a list ordered
49
+ * the other way round would blame the wrong rule for every such line.
50
+ */
51
+ export declare const NARRATIVE_MARKERS: ReadonlyArray<{
52
+ name: string;
53
+ pattern: RegExp;
54
+ }>;
55
+ export type CommentScanInput = {
56
+ /** Repo-relative path, e.g. `src/services/x/y.ts`. */
57
+ readonly file: string;
58
+ readonly source: string;
59
+ };
60
+ export type CommentScanOptions = {
61
+ /** Injected for tests; defaults to nothing-exists so a caller must decide. */
62
+ readonly exists?: (relPath: string) => boolean;
63
+ /** True for directories only; defaults to `exists`, which is true for both. */
64
+ readonly dirExists?: (relDir: string) => boolean;
65
+ /** Filesystem-only view of the tree, for the installed-package question.
66
+ * Defaults to `exists`. */
67
+ readonly installed?: (relPath: string) => boolean;
68
+ readonly kinds?: readonly CommentFindingKind[];
69
+ };
70
+ /** Every finding in one file, in line order. */
71
+ export declare function scanComments(input: CommentScanInput, options?: CommentScanOptions): CommentFinding[];
72
+ /** Per-file totals, which is the shape the gate ratchets and the worklist sorts by. */
73
+ export type CommentFileSummary = {
74
+ readonly file: string;
75
+ readonly commentLines: number;
76
+ readonly deadReferences: number;
77
+ readonly narrative: number;
78
+ };
79
+ export declare function summarize(input: CommentScanInput, findings: readonly CommentFinding[]): CommentFileSummary;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Which comments are still worth their bytes to the next reader.
3
+ *
4
+ * Two categories are reported as hygiene debt, and they fail in different
5
+ * directions, so they are kept separate rather than summed into one number:
6
+ *
7
+ * - `dead-reference` — the comment asserts that some file exists by naming a
8
+ * path, and that path resolves nowhere the citation is allowed to mean. This
9
+ * is checkable, and it is the class that has already cost this repository a
10
+ * wrong decision: the header of `src/services/prd/handoff-gate-evidence.ts`
11
+ * described a pre-existing "no producer" state in the same voice as the
12
+ * current one, a reader took it as fact, and the fix was chased into
13
+ * `dist/` instead of the source.
14
+ * - `narrative` — the comment is about the work rather than the code: slice
15
+ * and rid identifiers, `AC-3`, "F2 of `rid-…`", "this used to claim", "the
16
+ * test named above was deleted". Legitimately valuable in a commit message
17
+ * or `.peaks/docs/backlog.md`; in a source file it is re-read by every
18
+ * later agent, at their token cost, and it rots silently the moment the
19
+ * history it summarises moves.
20
+ *
21
+ * The narrative rule is a marker list, not a heuristic about length or
22
+ * sentence shape, precisely because a false positive here is worse than a false
23
+ * positive on `dead-reference`: a wrong `dead-reference` is visible in a diff as
24
+ * a removed path, while a wrong `narrative` deletes a reason someone wrote down
25
+ * on purpose. Every finding carries its matched marker for that reason — an
26
+ * unattributable finding is one nobody can review, and a rule nobody can review
27
+ * is a rule that will be widened until it stops mattering.
28
+ */
29
+ import { citationResolves, citations, commentLines } from './comment-citations.js';
30
+ import { isCitationCandidate } from './citation-rules.js';
31
+ /**
32
+ * Development-process markers. Each entry is a named rule so a report can say
33
+ * WHICH one fired — the failure mode this whole feature is modelled on is a
34
+ * refusal that could not name itself (the content scan in
35
+ * `src/services/memory/project-memory-service/store/atomic-write.ts` answered a
36
+ * ten-pattern `||` chain with `matchedTerm: null`).
37
+ *
38
+ * Order is significant and is the whole attribution: one line reports its FIRST
39
+ * match, so a specific marker must precede the general one that also fits it.
40
+ * `F2 of \`rid-…\`` is a finding reference, not a bare rid, and a list ordered
41
+ * the other way round would blame the wrong rule for every such line.
42
+ */
43
+ export const NARRATIVE_MARKERS = [
44
+ { name: 'finding-id', pattern: /\b[DF]\d+\s+of\b/ },
45
+ { name: 'acceptance-id', pattern: /\bAC-\d+\b/ },
46
+ {
47
+ name: 'slice-id',
48
+ pattern: /\bslice\s+(?:`?)?(?:20\d\d-\d\d-\d\d|rid-|change-|S\d+\b|0\d\d\b)/i
49
+ },
50
+ { name: 'change-folder', pattern: /\b20\d\d-\d\d-\d\d-[a-z]+-[a-z-]+/ },
51
+ { name: 'rid-id', pattern: /\brid-[a-z0-9][a-z0-9._-]*\b/i },
52
+ {
53
+ name: 'historical-voice',
54
+ pattern: /\b(used to (?:say|claim|read|write)|previously (?:named|said)|no longer (?:exists|ships|carries)|which never existed|did not exist)\b/i
55
+ },
56
+ { name: 'deleted-test-ref', pattern: /\b(?:tests?|specs?)\b[^.]{0,48}\b(?:was|were) deleted\b/i },
57
+ {
58
+ name: 'guard-self-reference',
59
+ pattern: /\bthis (?:comment|header|paragraph|note) (?:says|claimed|documents|is )/i
60
+ }
61
+ ];
62
+ function deadReferenceFindings(entry, judge) {
63
+ const ctx = { file: judge.file, dirExists: judge.dirExists };
64
+ const out = [];
65
+ for (const citation of citations(entry.text)) {
66
+ if (!isCitationCandidate(citation, entry.text, ctx))
67
+ continue;
68
+ if (citationResolves(citation.span, judge.file, judge.exists, judge.installed))
69
+ continue;
70
+ out.push({
71
+ file: judge.file,
72
+ line: entry.line,
73
+ kind: 'dead-reference',
74
+ matched: citation.span,
75
+ text: entry.text.trim()
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * One line, both readings. A line reports at most ONE narrative marker — its
82
+ * first — because the list is ordered specific-before-general, and a line that
83
+ * named every rule it matches would multiply the count without naming anything
84
+ * a reader could not already see.
85
+ */
86
+ function classifyLine(entry, judge) {
87
+ const out = judge.kinds.has('dead-reference')
88
+ ? deadReferenceFindings(entry, judge)
89
+ : [];
90
+ if (!judge.kinds.has('narrative'))
91
+ return out;
92
+ for (const marker of NARRATIVE_MARKERS) {
93
+ const hit = marker.pattern.exec(entry.text);
94
+ if (hit === null)
95
+ continue;
96
+ out.push({
97
+ file: judge.file,
98
+ line: entry.line,
99
+ kind: 'narrative',
100
+ matched: `${marker.name}:${hit[0].trim()}`,
101
+ text: entry.text.trim()
102
+ });
103
+ break;
104
+ }
105
+ return out;
106
+ }
107
+ /** Every finding in one file, in line order. */
108
+ export function scanComments(input, options = {}) {
109
+ const exists = options.exists ?? (() => false);
110
+ const judge = {
111
+ file: input.file,
112
+ exists,
113
+ installed: options.installed ?? exists,
114
+ dirExists: options.dirExists ?? exists,
115
+ kinds: new Set(options.kinds ?? ['dead-reference', 'narrative'])
116
+ };
117
+ const out = [];
118
+ for (const entry of commentLines(input.source)) {
119
+ out.push(...classifyLine(entry, judge));
120
+ }
121
+ return out;
122
+ }
123
+ function countKind(findings, kind) {
124
+ return findings.filter((finding) => finding.kind === kind).length;
125
+ }
126
+ export function summarize(input, findings) {
127
+ return {
128
+ file: input.file,
129
+ commentLines: commentLines(input.source).length,
130
+ deadReferences: countKind(findings, 'dead-reference'),
131
+ narrative: countKind(findings, 'narrative')
132
+ };
133
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Remove the comments the scan says are debt — and prove nothing else moved.
3
+ *
4
+ * The actuator is the dangerous half of this feature: a classifier that is 86%
5
+ * precise only misinforms, but a pruner that is 86% precise deletes. So the
6
+ * deliverable here is not the deletion, it is the PROOF, and the proof is a
7
+ * structural check run on every file before it is written:
8
+ *
9
+ * - the diff is computed line-by-line against the plan, so a changed line that
10
+ * the scan never named is a violation, not a surprise;
11
+ * - every removed line must be a comment line, and a stripped trailing comment
12
+ * must leave its code byte-identical;
13
+ * - a line that carries a block-comment delimiter opening or closing ELSEWHERE
14
+ * is never removed, because deleting it would turn the rest of the comment into
15
+ * code — the one failure mode that makes this a compile error rather than a
16
+ * lost sentence;
17
+ * - a file whose line endings are mixed is left alone, since re-joining it would
18
+ * rewrite every line and the proof would stop meaning anything.
19
+ *
20
+ * Anything that fails the check is reported and NOT written. The ledger records
21
+ * every line removed, with the rule that fired, so a prune is reviewable after the
22
+ * fact instead of only before it.
23
+ */
24
+ import type { CommentFinding, CommentFindingKind } from './comment-hygiene.js';
25
+ /** What the pruner will do to one line. */
26
+ export type PruneAction = {
27
+ readonly file: string;
28
+ readonly line: number;
29
+ readonly kind: CommentFindingKind;
30
+ readonly matched: string;
31
+ /** `drop-line` deletes the whole comment line; `strip-trailing` keeps the code. */
32
+ readonly mode: 'drop-line' | 'strip-trailing';
33
+ /** The full text of the line as it sits on disk right now. */
34
+ readonly lineText: string;
35
+ /** The comment as the SCAN saw it — for a trailing comment, not the whole line. */
36
+ readonly comment: string;
37
+ };
38
+ /** A finding the pruner refused to act on, with the reason. */
39
+ export type PruneSkip = {
40
+ readonly file: string;
41
+ readonly line: number;
42
+ readonly reason: string;
43
+ readonly text: string;
44
+ };
45
+ export type PruneOptions = {
46
+ readonly projectRoot: string;
47
+ /** Narrow to one category; the default is both, which is the accepted boundary. */
48
+ readonly kinds?: readonly CommentFindingKind[];
49
+ /** Restrict to one file, repo-relative — how a single-case proof is run. */
50
+ readonly onlyFile?: string;
51
+ /** Write the files. False (the default) plans and proves without touching disk. */
52
+ readonly apply?: boolean;
53
+ };
54
+ export type PruneResult = {
55
+ readonly scannedFiles: number;
56
+ readonly planned: number;
57
+ readonly dropped: number;
58
+ readonly stripped: number;
59
+ readonly skipped: number;
60
+ readonly touchedFiles: readonly string[];
61
+ readonly notWritten: readonly string[];
62
+ readonly ledgerPath: string;
63
+ readonly actions: readonly PruneAction[];
64
+ readonly skips: readonly PruneSkip[];
65
+ readonly applied: boolean;
66
+ };
67
+ /**
68
+ * Decide the fate of every finding.
69
+ *
70
+ * The line is re-read here and passed back through `commentLines`, the scan's own
71
+ * extractor, rather than compared against the whole line: a trailing comment's
72
+ * recorded text is the comment, not the code before it, and an equality test on the
73
+ * raw line would reject every one of them. Re-using the extractor is what makes the
74
+ * plan and the scan agree BY CONSTRUCTION instead of by two copies of a rule — the
75
+ * defect class this whole feature is a reply to.
76
+ */
77
+ export declare function planPrune(options: PruneOptions, findings: readonly CommentFinding[]): {
78
+ actions: PruneAction[];
79
+ skips: PruneSkip[];
80
+ };
81
+ /**
82
+ * Plan, prove, and (on `apply`) write.
83
+ *
84
+ * The scan runs through `auditComments`, so a pruned line is one the worklist
85
+ * already showed a human; the pruner adds no rule of its own about what counts as
86
+ * debt.
87
+ */
88
+ export declare function pruneComments(options: PruneOptions): PruneResult;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Remove the comments the scan says are debt — and prove nothing else moved.
3
+ *
4
+ * The actuator is the dangerous half of this feature: a classifier that is 86%
5
+ * precise only misinforms, but a pruner that is 86% precise deletes. So the
6
+ * deliverable here is not the deletion, it is the PROOF, and the proof is a
7
+ * structural check run on every file before it is written:
8
+ *
9
+ * - the diff is computed line-by-line against the plan, so a changed line that
10
+ * the scan never named is a violation, not a surprise;
11
+ * - every removed line must be a comment line, and a stripped trailing comment
12
+ * must leave its code byte-identical;
13
+ * - a line that carries a block-comment delimiter opening or closing ELSEWHERE
14
+ * is never removed, because deleting it would turn the rest of the comment into
15
+ * code — the one failure mode that makes this a compile error rather than a
16
+ * lost sentence;
17
+ * - a file whose line endings are mixed is left alone, since re-joining it would
18
+ * rewrite every line and the proof would stop meaning anything.
19
+ *
20
+ * Anything that fails the check is reported and NOT written. The ledger records
21
+ * every line removed, with the rule that fired, so a prune is reviewable after the
22
+ * fact instead of only before it.
23
+ */
24
+ import { mkdirSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { getSessionDir } from '../session/getSessionDir.js';
27
+ import { getSessionId } from '../session/session-manager.js';
28
+ import { auditComments } from './comment-audit.js';
29
+ import { commentLines } from './comment-citations.js';
30
+ import { actionsByFile, executePlan, readFileSafe, splitLines } from './prune-apply.js';
31
+ const FULL_LINE_PREFIXES = ['//', '/*', '/**', '*'];
32
+ function isFullLineComment(text) {
33
+ const trimmed = text.trim();
34
+ return FULL_LINE_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
35
+ }
36
+ /**
37
+ * Can this line be deleted without breaking the syntax around it?
38
+ *
39
+ * A line that OPENS a block (`/* …` with no close on the same line) or CLOSES one
40
+ * (a bare `* /`) is load-bearing for the comment's extent, not for its prose.
41
+ * Removing it leaves the remainder of the block as code — a compile error is the
42
+ * mildest thing that follows. Checked for both modes, because the strip below also
43
+ * cuts text out of a line: a code line carrying an unterminated `/*` would have its
44
+ * "trailing comment" extracted from inside the block, and cutting there is a syntax
45
+ * edit, not a comment edit.
46
+ */
47
+ function removalWouldBreakTheComment(text) {
48
+ const opens = text.includes('/*');
49
+ const closes = text.includes('*/');
50
+ return opens !== closes;
51
+ }
52
+ function actionFor(finding, text, comment) {
53
+ return {
54
+ file: finding.file,
55
+ line: finding.line,
56
+ kind: finding.kind,
57
+ matched: finding.matched,
58
+ mode: isFullLineComment(text) ? 'drop-line' : 'strip-trailing',
59
+ lineText: text,
60
+ comment
61
+ };
62
+ }
63
+ function skipFor(finding, reason) {
64
+ return { file: finding.file, line: finding.line, reason, text: finding.text };
65
+ }
66
+ /**
67
+ * Decide the fate of every finding.
68
+ *
69
+ * The line is re-read here and passed back through `commentLines`, the scan's own
70
+ * extractor, rather than compared against the whole line: a trailing comment's
71
+ * recorded text is the comment, not the code before it, and an equality test on the
72
+ * raw line would reject every one of them. Re-using the extractor is what makes the
73
+ * plan and the scan agree BY CONSTRUCTION instead of by two copies of a rule — the
74
+ * defect class this whole feature is a reply to.
75
+ */
76
+ export function planPrune(options, findings) {
77
+ const actions = [];
78
+ const skips = [];
79
+ for (const finding of findings) {
80
+ const source = readFileSafe(options.projectRoot, finding.file);
81
+ if (source === null) {
82
+ skips.push(skipFor(finding, 'file-not-readable'));
83
+ continue;
84
+ }
85
+ const text = splitLines(source).lines[finding.line - 1] ?? '';
86
+ const comment = commentLines(text)[0]?.text ?? '';
87
+ if (comment.trim() !== finding.text) {
88
+ skips.push(skipFor(finding, 'line-moved-since-scan'));
89
+ continue;
90
+ }
91
+ if (removalWouldBreakTheComment(text)) {
92
+ skips.push(skipFor(finding, 'carries-a-block-delimiter'));
93
+ continue;
94
+ }
95
+ actions.push(actionFor(finding, text, comment));
96
+ }
97
+ return { actions, skips };
98
+ }
99
+ function ledgerPathFor(projectRoot, stamp) {
100
+ const sessionId = getSessionId(projectRoot) ?? '_nonsession';
101
+ const dir = join(getSessionDir(projectRoot, sessionId), 'comments');
102
+ mkdirSync(dir, { recursive: true });
103
+ return join(dir, `prune-${stamp}.json`);
104
+ }
105
+ /**
106
+ * Plan, prove, and (on `apply`) write.
107
+ *
108
+ * The scan runs through `auditComments`, so a pruned line is one the worklist
109
+ * already showed a human; the pruner adds no rule of its own about what counts as
110
+ * debt.
111
+ */
112
+ export function pruneComments(options) {
113
+ const kinds = options.kinds ?? ['dead-reference', 'narrative'];
114
+ const audit = auditComments({ projectRoot: options.projectRoot });
115
+ const inScope = audit.findings.filter((finding) => kinds.includes(finding.kind) &&
116
+ (options.onlyFile === undefined || finding.file === options.onlyFile));
117
+ const { actions, skips } = planPrune(options, inScope);
118
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
119
+ const ledgerPath = ledgerPathFor(options.projectRoot, stamp);
120
+ const outcome = executePlan(options.projectRoot, actionsByFile(actions), options.apply === true);
121
+ const skipsAll = [...skips, ...outcome.fileSkips];
122
+ // Written on a dry run too: the plan IS the reviewable artifact, and a reviewer
123
+ // who only sees stdout has nothing to diff a later apply against.
124
+ writeFileSync(ledgerPath, `${JSON.stringify({
125
+ projectRoot: options.projectRoot,
126
+ at: new Date().toISOString(),
127
+ applied: options.apply === true,
128
+ kinds,
129
+ onlyFile: options.onlyFile ?? null,
130
+ removed: actions,
131
+ skipped: skipsAll,
132
+ touchedFiles: outcome.touched,
133
+ notWritten: outcome.notWritten
134
+ }, null, 2)}\n`);
135
+ return {
136
+ scannedFiles: audit.scannedFiles,
137
+ planned: actions.length,
138
+ dropped: actions.filter((action) => action.mode === 'drop-line').length,
139
+ stripped: actions.filter((action) => action.mode === 'strip-trailing').length,
140
+ skipped: skipsAll.length,
141
+ touchedFiles: outcome.touched,
142
+ notWritten: outcome.notWritten,
143
+ ledgerPath,
144
+ actions,
145
+ skips: skipsAll,
146
+ applied: options.apply === true
147
+ };
148
+ }