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.
- package/CHANGELOG.md +25 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/_register.js +2 -2
- package/dist/cli/commands/code-mode-gate-should-pause-command.js +1 -1
- package/dist/cli/commands/comments-commands.d.ts +14 -0
- package/dist/cli/commands/comments-commands.js +96 -0
- package/dist/cli/commands/core/memory-command.js +10 -0
- package/dist/cli/commands/core/skill-command.js +1 -1
- package/dist/cli/commands/core/standards-command.js +1 -1
- package/dist/cli/commands/ecc-commands.d.ts +17 -22
- package/dist/cli/commands/ecc-commands.js +38 -26
- package/dist/cli/commands/prd-commands.js +8 -2
- package/dist/cli/program.js +4 -9
- package/dist/services/code/mode-gate-types.d.ts +1 -1
- package/dist/services/code/mode-gate-types.js +0 -1
- package/dist/services/code/mode-gate.js +1 -9
- package/dist/services/code/user-touchpoint-classifier.js +0 -7
- package/dist/services/code-review/ecc-bridge.d.ts +6 -6
- package/dist/services/comments/citation-rules.d.ts +154 -0
- package/dist/services/comments/citation-rules.js +241 -0
- package/dist/services/comments/comment-audit.d.ts +57 -0
- package/dist/services/comments/comment-audit.js +100 -0
- package/dist/services/comments/comment-citations.d.ts +60 -0
- package/dist/services/comments/comment-citations.js +186 -0
- package/dist/services/comments/comment-hygiene.d.ts +79 -0
- package/dist/services/comments/comment-hygiene.js +133 -0
- package/dist/services/comments/comment-prune.d.ts +88 -0
- package/dist/services/comments/comment-prune.js +148 -0
- package/dist/services/comments/prune-apply.d.ts +60 -0
- package/dist/services/comments/prune-apply.js +150 -0
- package/dist/services/comments/repo-path-probe.d.ts +43 -0
- package/dist/services/comments/repo-path-probe.js +78 -0
- package/dist/services/log/retention.d.ts +0 -16
- package/dist/services/log/retention.js +0 -17
- package/dist/services/memory/project-memory-service/index.d.ts +1 -1
- package/dist/services/memory/project-memory-service/index.js +1 -1
- package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +19 -7
- package/dist/services/memory/project-memory-service/store/atomic-write.js +120 -26
- package/dist/services/prd/handoff-frontmatter.js +61 -0
- package/dist/services/prd/handoff-gate-evidence.js +14 -10
- package/dist/services/prd/handoff-service.d.ts +11 -1
- package/dist/services/prd/handoff-service.js +11 -1
- package/dist/services/prd/handoff-types.d.ts +33 -1
- package/dist/services/recommendations/installed-capability-detector.d.ts +5 -5
- package/dist/services/recommendations/installed-capability-detector.js +11 -10
- package/dist/services/scan/archetype-detection.d.ts +37 -0
- package/dist/services/scan/archetype-detection.js +175 -2
- package/dist/services/scan/archetype-service.js +36 -22
- package/dist/services/scan/scan-types.d.ts +9 -0
- package/dist/services/workspace/generated-artifacts-stamp.d.ts +2 -2
- package/dist/services/workspace/generated-artifacts-stamp.js +2 -2
- package/dist/services/workspace/workspace-service.js +1 -1
- package/package.json +5 -5
- package/scripts/install-skills.mjs +0 -177
- package/skills/bee/peaks-qa/references/reading-handoff-frontmatter.md +3 -1
- package/skills/bee/peaks-rd/references/parallel-review-fanout.md +1 -1
- package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +3 -2
- package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +29 -7
- package/skills/peaks-code/references/frontend-only-mode.md +2 -2
- package/skills/peaks-code/references/startup-sequence.md +0 -4
- package/dist/cli/commands/upgrade-commands.d.ts +0 -25
- package/dist/cli/commands/upgrade-commands.js +0 -154
- package/dist/services/upgrade/1x-detector-service.d.ts +0 -7
- package/dist/services/upgrade/1x-detector-service.js +0 -96
- package/dist/services/upgrade/gitignore-migrate-service.d.ts +0 -56
- package/dist/services/upgrade/gitignore-migrate-service.js +0 -170
- package/dist/services/upgrade/upgrade-service.d.ts +0 -81
- package/dist/services/upgrade/upgrade-service.js +0 -428
- 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
|
+
}
|