@nurix/etna 0.20.15 → 0.20.17
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/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +8 -2
- package/data/harnesses/code/agents/bookkeeper.md +1 -0
- package/data/harnesses/code/agents/polygraph-finder.md +4 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +16 -3
- package/data/harnesses/code/hooks/lib/state.mjs +1 -0
- package/data/harnesses/code/hooks/lib/sweep.mjs +21 -3
- package/data/harnesses/code/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/code/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +42 -10
- package/data/harnesses/code/skills/documentation/scripts/source-lint.mjs +222 -0
- package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/code/skills/polygraph/SKILL.md +1 -1
- package/data/harnesses/code/skills/testing/SKILL.md +1 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/delivery/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +42 -10
- package/data/harnesses/delivery/skills/documentation/scripts/source-lint.mjs +222 -0
- package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/etna/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +42 -10
- package/data/harnesses/etna/skills/documentation/scripts/source-lint.mjs +222 -0
- package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// skills/documentation/scripts/comment-strip.mjs - Proves a comment sweep changed no executable code.
|
|
3
|
+
// Strips comments from a git revision of each file and from the working copy, then compares the two
|
|
4
|
+
// stripped forms; a difference is a code change hiding inside a comments-only pass. String and
|
|
5
|
+
// template literals, character literals, regex literals, and raw strings are honoured, so a `//`
|
|
6
|
+
// inside a string is code and stays.
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { join, extname } from "node:path";
|
|
12
|
+
|
|
13
|
+
// Comment syntax per extension. `block` is null where the language has none.
|
|
14
|
+
const DIALECTS = {
|
|
15
|
+
".ts": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
16
|
+
".tsx": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
17
|
+
".mts": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
18
|
+
".js": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
19
|
+
".jsx": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
20
|
+
".mjs": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
21
|
+
".cjs": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
22
|
+
".css": { line: null, block: ["/*", "*/"], strings: `'"`, regex: false , sameLineQuotes: true },
|
|
23
|
+
".rs": { line: "//", block: ["/*", "*/"], strings: `"`, regex: false, rustRaw: true , sameLineQuotes: true },
|
|
24
|
+
".go": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: false , sameLineQuotes: true },
|
|
25
|
+
".java": { line: "//", block: ["/*", "*/"], strings: `'"`, regex: false , sameLineQuotes: true },
|
|
26
|
+
".swift": { line: "//", block: ["/*", "*/"], strings: `"`, regex: false , sameLineQuotes: true },
|
|
27
|
+
".kt": { line: "//", block: ["/*", "*/"], strings: `"`, regex: false , sameLineQuotes: true },
|
|
28
|
+
".sql": { line: "--", block: ["/*", "*/"], strings: `'"`, regex: false },
|
|
29
|
+
".py": { line: "#", block: null, strings: `'"`, regex: false },
|
|
30
|
+
".sh": { line: "#", block: null, strings: `'"`, regex: false },
|
|
31
|
+
".zsh": { line: "#", block: null, strings: `'"`, regex: false },
|
|
32
|
+
".yml": { line: "#", block: null, strings: `'"`, regex: false },
|
|
33
|
+
".yaml": { line: "#", block: null, strings: `'"`, regex: false },
|
|
34
|
+
".toml": { line: "#", block: null, strings: `'"`, regex: false },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Whether a `/` at this position opens a regex literal rather than dividing — decided by the last
|
|
38
|
+
// significant character, the same way a JavaScript tokenizer decides it.
|
|
39
|
+
function regexCanStart(prev) {
|
|
40
|
+
if (prev === "") return true;
|
|
41
|
+
return !/[\w$)\]]/.test(prev);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The index just past a template literal starting at `i`, following `${…}` interpolations and the
|
|
45
|
+
// templates and quotes nested inside them. A scan that stops at the first backtick it meets closes the
|
|
46
|
+
// OUTER template on an INNER one — `${x ? `<b>${y}</b>` : ""}` — and every quote and comment downstream
|
|
47
|
+
// is then read in the wrong context, which reports a whole file as changed code.
|
|
48
|
+
function templateEnd(source, i) {
|
|
49
|
+
const n = source.length;
|
|
50
|
+
let j = i + 1;
|
|
51
|
+
while (j < n) {
|
|
52
|
+
const c = source[j];
|
|
53
|
+
if (c === "\\") {
|
|
54
|
+
j += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === "`") return j + 1;
|
|
58
|
+
if (c === "$" && source[j + 1] === "{") {
|
|
59
|
+
let depth = 1;
|
|
60
|
+
j += 2;
|
|
61
|
+
while (j < n && depth > 0) {
|
|
62
|
+
const d = source[j];
|
|
63
|
+
if (d === "\\") {
|
|
64
|
+
j += 2;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (d === "`") {
|
|
68
|
+
j = templateEnd(source, j);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (d === "'" || d === '"') {
|
|
72
|
+
const quote = d;
|
|
73
|
+
j++;
|
|
74
|
+
while (j < n && source[j] !== quote && source[j] !== "\n") {
|
|
75
|
+
if (source[j] === "\\") j++;
|
|
76
|
+
j++;
|
|
77
|
+
}
|
|
78
|
+
j++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (d === "{") depth++;
|
|
82
|
+
else if (d === "}") depth--;
|
|
83
|
+
j++;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
j++;
|
|
88
|
+
}
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remove every comment from `source`, preserving all other bytes including newlines.
|
|
93
|
+
export function stripComments(source, dialect) {
|
|
94
|
+
const { line, block, strings, regex, rustRaw } = dialect;
|
|
95
|
+
const out = [];
|
|
96
|
+
let i = 0;
|
|
97
|
+
let lastSignificant = "";
|
|
98
|
+
const n = source.length;
|
|
99
|
+
while (i < n) {
|
|
100
|
+
const ch = source[i];
|
|
101
|
+
const two = source.slice(i, i + 2);
|
|
102
|
+
|
|
103
|
+
if (rustRaw && ch === "r" && (source[i + 1] === "#" || source[i + 1] === '"')) {
|
|
104
|
+
let j = i + 1;
|
|
105
|
+
let hashes = 0;
|
|
106
|
+
while (source[j] === "#") {
|
|
107
|
+
hashes++;
|
|
108
|
+
j++;
|
|
109
|
+
}
|
|
110
|
+
if (source[j] === '"') {
|
|
111
|
+
const close = '"' + "#".repeat(hashes);
|
|
112
|
+
const end = source.indexOf(close, j + 1);
|
|
113
|
+
const stop = end === -1 ? n : end + close.length;
|
|
114
|
+
out.push(source.slice(i, stop));
|
|
115
|
+
lastSignificant = '"';
|
|
116
|
+
i = stop;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (line && two.slice(0, line.length) === line) {
|
|
122
|
+
const nl = source.indexOf("\n", i);
|
|
123
|
+
i = nl === -1 ? n : nl;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (block && two === block[0]) {
|
|
128
|
+
const end = source.indexOf(block[1], i + 2);
|
|
129
|
+
const stop = end === -1 ? n : end + block[1].length;
|
|
130
|
+
// A JSX expression container holding only a comment — `{/* … */}` — is comment syntax
|
|
131
|
+
// whole: the braces exist to carry it, so they leave with it. Left behind, a multi-line
|
|
132
|
+
// container collapses to `{}` and reads as a code change.
|
|
133
|
+
let after = stop;
|
|
134
|
+
while (after < n && /\s/.test(source[after])) after++;
|
|
135
|
+
let brace = out.length - 1;
|
|
136
|
+
while (brace >= 0 && out[brace].trim() === "") brace--;
|
|
137
|
+
if (regex && source[after] === "}" && brace >= 0 && out[brace] === "{") {
|
|
138
|
+
out.splice(brace, 1);
|
|
139
|
+
for (const c of source.slice(i, after + 1)) if (c === "\n") out.push("\n");
|
|
140
|
+
i = after + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const c of source.slice(i, stop)) if (c === "\n") out.push("\n");
|
|
144
|
+
i = stop;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch === "`" && regex) {
|
|
149
|
+
const stop = templateEnd(source, i);
|
|
150
|
+
out.push(source.slice(i, stop));
|
|
151
|
+
lastSignificant = "`";
|
|
152
|
+
i = stop;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (strings.includes(ch)) {
|
|
157
|
+
// A quote opens a literal only where one can actually close. In the C family and in Rust a
|
|
158
|
+
// `'` or `"` literal may not contain a raw newline, so requiring the partner on the same
|
|
159
|
+
// line is not a heuristic — it is the grammar, and it is what keeps an apostrophe in JSX
|
|
160
|
+
// text (`Couldn't load`) and a Rust char literal holding a quote (`trim_matches('"')`) from
|
|
161
|
+
// opening a string that swallows the rest of the file. Backticks span lines by design.
|
|
162
|
+
if (ch !== "`" && dialect.sameLineQuotes) {
|
|
163
|
+
let j = i + 1;
|
|
164
|
+
let closed = false;
|
|
165
|
+
while (j < n && source[j] !== "\n") {
|
|
166
|
+
if (source[j] === "\\") {
|
|
167
|
+
j += 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (source[j] === ch) {
|
|
171
|
+
closed = true;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
j++;
|
|
175
|
+
}
|
|
176
|
+
if (!closed) {
|
|
177
|
+
out.push(ch);
|
|
178
|
+
lastSignificant = ch;
|
|
179
|
+
i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
out.push(source.slice(i, j + 1));
|
|
183
|
+
lastSignificant = ch;
|
|
184
|
+
i = j + 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
out.push(ch);
|
|
188
|
+
i++;
|
|
189
|
+
while (i < n) {
|
|
190
|
+
if (source[i] === "\\") {
|
|
191
|
+
out.push(source.slice(i, i + 2));
|
|
192
|
+
i += 2;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
out.push(source[i]);
|
|
196
|
+
if (source[i] === ch) {
|
|
197
|
+
i++;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
lastSignificant = ch;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (regex && ch === "/" && regexCanStart(lastSignificant)) {
|
|
207
|
+
let j = i + 1;
|
|
208
|
+
let inClass = false;
|
|
209
|
+
let closed = false;
|
|
210
|
+
while (j < n) {
|
|
211
|
+
const c = source[j];
|
|
212
|
+
if (c === "\\") {
|
|
213
|
+
j += 2;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (c === "\n") break;
|
|
217
|
+
if (c === "[") inClass = true;
|
|
218
|
+
else if (c === "]") inClass = false;
|
|
219
|
+
else if (c === "/" && !inClass) {
|
|
220
|
+
closed = true;
|
|
221
|
+
j++;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
j++;
|
|
225
|
+
}
|
|
226
|
+
if (closed) {
|
|
227
|
+
out.push(source.slice(i, j));
|
|
228
|
+
lastSignificant = "/";
|
|
229
|
+
i = j;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
out.push(ch);
|
|
235
|
+
if (!/\s/.test(ch)) lastSignificant = ch;
|
|
236
|
+
i++;
|
|
237
|
+
}
|
|
238
|
+
// Collapse whitespace so a re-indent left behind by a removed block comment is not a finding.
|
|
239
|
+
return out
|
|
240
|
+
.join("")
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
243
|
+
.filter((l) => l !== "")
|
|
244
|
+
.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Comment-only lines in a source: the non-blank lines it has, less the lines that survive stripping.
|
|
248
|
+
// Both sides exclude blanks, so the difference is comments alone; a trailing comment on a code line is
|
|
249
|
+
// correctly not counted, because the line itself survives.
|
|
250
|
+
function countCommentLines(source, dialect) {
|
|
251
|
+
const nonBlank = source.split("\n").filter((l) => l.trim() !== "").length;
|
|
252
|
+
const stripped = stripComments(source, dialect);
|
|
253
|
+
return nonBlank - (stripped === "" ? 0 : stripped.split("\n").length);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function gitShow(rev, path) {
|
|
257
|
+
try {
|
|
258
|
+
return execFileSync("git", ["show", `${rev}:${path}`], { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function walk(dir, acc = []) {
|
|
265
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
266
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
267
|
+
const full = join(dir, entry.name);
|
|
268
|
+
if (entry.isDirectory()) walk(full, acc);
|
|
269
|
+
else if (DIALECTS[extname(entry.name)]) acc.push(full);
|
|
270
|
+
}
|
|
271
|
+
return acc;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function main(argv) {
|
|
275
|
+
let rev = "HEAD";
|
|
276
|
+
// With `--to`, both sides come from git and the working tree is not read — the mode that
|
|
277
|
+
// audits a sweep somebody already committed.
|
|
278
|
+
let to = null;
|
|
279
|
+
const paths = [];
|
|
280
|
+
for (let i = 0; i < argv.length; i++) {
|
|
281
|
+
if (argv[i] === "--rev") rev = argv[++i];
|
|
282
|
+
else if (argv[i] === "--to") to = argv[++i];
|
|
283
|
+
else if (argv[i] === "--changed") {
|
|
284
|
+
const out = execFileSync("git", ["diff", "--name-only", rev], { encoding: "utf8" });
|
|
285
|
+
paths.push(...out.split("\n").filter(Boolean));
|
|
286
|
+
} else paths.push(argv[i]);
|
|
287
|
+
}
|
|
288
|
+
if (paths.length === 0) {
|
|
289
|
+
const out = execFileSync("git", ["diff", "--name-only", rev], { encoding: "utf8" });
|
|
290
|
+
paths.push(...out.split("\n").filter(Boolean));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const files = [];
|
|
294
|
+
for (const p of paths) {
|
|
295
|
+
// A revision-to-revision run names paths that need not exist in the checkout.
|
|
296
|
+
if (to !== null) {
|
|
297
|
+
if (DIALECTS[extname(p)]) files.push(p);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!existsSync(p)) continue;
|
|
301
|
+
if (statSync(p).isDirectory()) files.push(...walk(p));
|
|
302
|
+
else if (DIALECTS[extname(p)]) files.push(p);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let changed = 0;
|
|
306
|
+
let checked = 0;
|
|
307
|
+
let commentDelta = 0;
|
|
308
|
+
for (const file of [...new Set(files)]) {
|
|
309
|
+
const dialect = DIALECTS[extname(file)];
|
|
310
|
+
const before = gitShow(rev, file);
|
|
311
|
+
if (before === null) continue;
|
|
312
|
+
const after = to === null ? readFileSync(file, "utf8") : gitShow(to, file);
|
|
313
|
+
if (after === null) continue;
|
|
314
|
+
const a = stripComments(before, dialect);
|
|
315
|
+
const b = stripComments(after, dialect);
|
|
316
|
+
checked++;
|
|
317
|
+
// Count comment lines directly. Differencing raw length against stripped length would fold in the
|
|
318
|
+
// blank lines the strip also drops, and report a comment+blank delta under a comment label.
|
|
319
|
+
const cb = countCommentLines(before, dialect);
|
|
320
|
+
const ca = countCommentLines(after, dialect);
|
|
321
|
+
commentDelta += ca - cb;
|
|
322
|
+
if (a !== b) {
|
|
323
|
+
changed++;
|
|
324
|
+
const al = a.split("\n");
|
|
325
|
+
const bl = b.split("\n");
|
|
326
|
+
let k = 0;
|
|
327
|
+
while (k < al.length && k < bl.length && al[k] === bl[k]) k++;
|
|
328
|
+
console.log(`CODE CHANGED ${file}`);
|
|
329
|
+
console.log(` ${rev}: ${al[k] === undefined ? "(end of file)" : al[k].slice(0, 120)}`);
|
|
330
|
+
console.log(` now: ${bl[k] === undefined ? "(end of file)" : bl[k].slice(0, 120)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const verdict = changed === 0 ? "no executable code changed" : `${changed} file(s) changed code`;
|
|
335
|
+
console.log(`\n${checked} file(s) compared against ${rev} — ${verdict}; comment lines ${commentDelta >= 0 ? "+" : ""}${commentDelta}.`);
|
|
336
|
+
process.exit(changed === 0 ? 0 : 1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2));
|
|
@@ -17,19 +17,36 @@ const RULES = [
|
|
|
17
17
|
{ id: 'status-glyph', severity: 'warn', scope: 'truth', exempt: /(^|\/)docs\/(tests|operations)\//, pattern: /(^|\s)(✅|⏳|- \[[ x]\])/u, message: 'status glyph / checklist in a truth doc — tracking belongs in a record' },
|
|
18
18
|
// Truth docs are supposed to cite records, and record filenames are dated — so a date inside a link
|
|
19
19
|
// target or a code span is a path, not narration. Strip those before matching.
|
|
20
|
-
{ id: 'date-in-truth-doc', severity: '
|
|
20
|
+
{ id: 'date-in-truth-doc', severity: 'block', scope: 'truth', strip: /\[[^\]]*\]\([^)]*\)|`[^`]*`|^(last-reviewed|last-updated|rev|harness-version):.*$/gm, pattern: /\b20\d{2}-\d{2}-\d{2}\b/, message: 'date in a truth doc — cite the record by name and link; the closed exceptions are the frontmatter tooling keys, an AD Decision Changelog tail and structure.md\'s Health Log tail' },
|
|
21
21
|
];
|
|
22
22
|
|
|
23
23
|
// Sections inside a truth doc where dated history is sanctioned (the two bridges).
|
|
24
24
|
const BRIDGE_HEADINGS = /^#{1,4}\s+(decision changelog|health log)\b/i;
|
|
25
|
-
// Instruction files and generated trees are
|
|
26
|
-
|
|
27
|
-
const
|
|
25
|
+
// Instruction files, the three harness-installed mirrors and generated trees are out of scope; the authoring
|
|
26
|
+
// roots are anchored to the repo root.
|
|
27
|
+
const SKIP = /^(\.claude|\.agents|\.codex|skills|rules|agents|harness)\/|(^|\/)(node_modules|packages\/[^/]+\/data)\/|(^|\/)(CLAUDE|AGENTS)\.md$|\.bkp\./;
|
|
28
|
+
// `operations/issues/YYYY-MM-DD-{slug}.md` is a sanctioned record genre (`type: issue`, the
|
|
29
|
+
// `issue-bridge` skill's local pack) living outside the record folders, so it is named here too —
|
|
30
|
+
// graded as a truth document it fails the date rule for carrying the date its filename already does.
|
|
31
|
+
const RECORD = /(^|\/)docs\/(history|research|memory)\/|(^|\/)docs\/operations\/issues\//;
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
const RECORD_TYPES = /^(changelog-entry|plan|migration|migration-runbook|research|brainstorm|journal|record|ledger|issue|triage|project-memory|agent-memory)$/;
|
|
34
|
+
const TRUTH_LOCATIONS = /(^|\/)docs\/(features|architecture|tests|acceptance)\//;
|
|
35
|
+
|
|
36
|
+
// The genre a document declares for itself, from its own frontmatter `type:` key, or null.
|
|
37
|
+
function declaredType(text) {
|
|
38
|
+
if (!text.startsWith('---')) return null;
|
|
39
|
+
const end = text.indexOf('\n---', 3);
|
|
40
|
+
const m = /^type:\s*(\S+)\s*$/m.exec(end === -1 ? text : text.slice(0, end));
|
|
41
|
+
return m ? m[1] : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Classify a document: record or truth, the declared genre outranking the path.
|
|
45
|
+
function scopeOf(path, text) {
|
|
31
46
|
if (SKIP.test(path)) return 'skip';
|
|
32
|
-
|
|
47
|
+
if (RECORD.test(path)) return 'record';
|
|
48
|
+
const declared = text === undefined ? null : declaredType(text);
|
|
49
|
+
return declared && RECORD_TYPES.test(declared) ? 'record' : 'truth';
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
// Yield prose lines only: frontmatter, fenced code blocks, and bridge sections drop out.
|
|
@@ -37,13 +54,21 @@ function proseLines(text) {
|
|
|
37
54
|
const lines = text.split('\n');
|
|
38
55
|
const out = [];
|
|
39
56
|
let inFence = false;
|
|
57
|
+
let fenceMarker = '';
|
|
40
58
|
let inFrontmatter = lines[0] === '---';
|
|
41
59
|
let inBridge = false;
|
|
42
60
|
let bridgeLevel = 0;
|
|
43
61
|
for (let i = inFrontmatter ? 1 : 0; i < lines.length; i++) {
|
|
44
62
|
const line = lines[i];
|
|
45
63
|
if (inFrontmatter) { if (line === '---') inFrontmatter = false; continue; }
|
|
46
|
-
|
|
64
|
+
// A fence closes only on a marker at least as long as the one that opened it, so a nested ```yaml
|
|
65
|
+
// inside a ```markdown block does not invert the state and expose the outer block as prose.
|
|
66
|
+
const fence = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
67
|
+
if (fence) {
|
|
68
|
+
if (!inFence) { inFence = true; fenceMarker = fence[1]; }
|
|
69
|
+
else if (fence[1][0] === fenceMarker[0] && fence[1].length >= fenceMarker.length) inFence = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
47
72
|
if (inFence) continue;
|
|
48
73
|
const heading = line.match(/^(#{1,4})\s/);
|
|
49
74
|
if (heading) {
|
|
@@ -64,9 +89,9 @@ function fileDisables(text) {
|
|
|
64
89
|
}
|
|
65
90
|
|
|
66
91
|
function lintFile(path, findings) {
|
|
67
|
-
|
|
68
|
-
if (scope === 'skip' || !existsSync(path)) return;
|
|
92
|
+
if (SKIP.test(path) || !existsSync(path)) return;
|
|
69
93
|
const text = readFileSync(path, 'utf8');
|
|
94
|
+
const scope = scopeOf(path, text);
|
|
70
95
|
const disabled = fileDisables(text);
|
|
71
96
|
const lines = proseLines(text);
|
|
72
97
|
|
|
@@ -85,6 +110,13 @@ function lintFile(path, findings) {
|
|
|
85
110
|
}
|
|
86
111
|
}
|
|
87
112
|
|
|
113
|
+
// A record genre declared in a settled truth location is a contradiction, not a classification.
|
|
114
|
+
if (TRUTH_LOCATIONS.test(path)) {
|
|
115
|
+
const declared = declaredType(text);
|
|
116
|
+
if (declared && RECORD_TYPES.test(declared) && !disabled.has('record-type-in-truth-location'))
|
|
117
|
+
findings.push({ path, n: 1, severity: 'block', id: 'record-type-in-truth-location', message: `\`type: ${declared}\` is a record genre declared in a truth location — move the file to \`docs/history/\` if it is a record, or give it its location's genre` });
|
|
118
|
+
}
|
|
119
|
+
|
|
88
120
|
// A folder index (Readme.md/README.md) is a browse page, not a feature doc — it owns no code.
|
|
89
121
|
const isFolderIndex = /(^|\/)(readme)\.md$/i.test(path);
|
|
90
122
|
if (scope === 'truth' && !isFolderIndex && /(^|\/)docs\/features\/[^/]+\.md$/.test(path)) {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// skills/documentation/scripts/source-lint.mjs - mechanical checks over source comments (header path, density, tense, shouting)
|
|
2
|
+
// Usage: node source-lint.mjs <file | dir ...> | node source-lint.mjs --changed (git-changed + untracked source files)
|
|
3
|
+
// Exit 1 when any block-severity finding survives. Escape hatch: a `source-lint-disable: id[, id]` comment,
|
|
4
|
+
// file-wide in the first 20 lines or on the offending line; the reason goes in a second comment beside it.
|
|
5
|
+
// The convention itself is the harness `CLAUDE.md` § File Structure; this checks only what is mechanical.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { basename, extname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Comment syntax per extension, and whether a leading `#!` shebang may precede the header.
|
|
12
|
+
const DIALECTS = {
|
|
13
|
+
'.ts': '//', '.tsx': '//', '.mts': '//', '.cts': '//',
|
|
14
|
+
'.js': '//', '.jsx': '//', '.mjs': '//', '.cjs': '//',
|
|
15
|
+
'.rs': '//', '.go': '//', '.java': '//', '.kt': '//', '.swift': '//', '.c': '//', '.h': '//', '.cpp': '//',
|
|
16
|
+
'.css': '/*', '.scss': '//',
|
|
17
|
+
'.sql': '--',
|
|
18
|
+
'.py': '#', '.sh': '#', '.zsh': '#', '.bash': '#', '.rb': '#',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Generated trees, vendored copies, and formats with no comment syntax are a different genre. `.claude/`,
|
|
22
|
+
// `.agents/` and `.codex/` are harness-owned in every consumer — overwritten on the next roll, so a finding
|
|
23
|
+
// there names work nobody in that repo can land. The forge lints its own copies at `harness/`, not here.
|
|
24
|
+
const SKIP = /(^|\/)(node_modules|dist|build|target|\.git|\.claude|\.agents|\.codex|coverage|__screenshots__|data\/harnesses)\/|\.min\.|\.d\.ts$|(^|\/)(pnpm-lock|package-lock)\./;
|
|
25
|
+
// A generated file says so in its own first lines; the convention exempts it.
|
|
26
|
+
const GENERATED = /@generated|DO NOT EDIT|AUTO-GENERATED|This file was generated/i;
|
|
27
|
+
// Below this a file is too small for a density ratio to mean anything.
|
|
28
|
+
const RATIO_MIN_LINES = 40;
|
|
29
|
+
// A file-level ceiling, not a target. The house style sits near 14%; a file past this is carrying prose.
|
|
30
|
+
// It grades INHERITED density, so it only warns: a file somebody else wrote at 42% is not this session's
|
|
31
|
+
// debt, and a wall that blocks on it stops every commit in the repo until a rewrite pass lands.
|
|
32
|
+
const RATIO_CEILING = 0.35;
|
|
33
|
+
// The ceiling that actually holds the line, because it grades only the lines THIS change adds. The measured
|
|
34
|
+
// regression that prompted the rule ran at 28% on new files while the tree sat at 14% — a file-level 35%
|
|
35
|
+
// ceiling passes that cleanly, so the file-level rule cannot be what stops it. You own what you add.
|
|
36
|
+
const ADDED_RATIO_CEILING = 0.25;
|
|
37
|
+
// Below this an added-line ratio is noise — a two-line fix with one comment is not a prose problem.
|
|
38
|
+
const ADDED_MIN_LINES = 20;
|
|
39
|
+
// The header's own ceiling in wrapped lines, from the measured p99 of the convention's own sweep.
|
|
40
|
+
const HEADER_MAX_LINES = 6;
|
|
41
|
+
// A comment block longer than this is a document living in a source file.
|
|
42
|
+
const BLOCK_MAX_LINES = 12;
|
|
43
|
+
|
|
44
|
+
const RULES = {
|
|
45
|
+
'header-missing': { severity: 'block', message: 'no file-path header comment — add one naming this file\'s repo-relative path' },
|
|
46
|
+
'header-path-stale': { severity: 'block', message: 'the header names another path — a header a move left behind reads as current' },
|
|
47
|
+
'header-too-long': { severity: 'warn', message: `header past ${HEADER_MAX_LINES} lines — two or three sentences, and the fuller account goes in its doc` },
|
|
48
|
+
'comment-ratio': { severity: 'warn', message: `comment lines over ${Math.round(RATIO_CEILING * 100)}% of the file — the rationale belongs in the owning doc` },
|
|
49
|
+
'comment-ratio-added': { severity: 'block', message: `over ${Math.round(ADDED_RATIO_CEILING * 100)}% of the lines this change adds are comments — one sentence per symbol, and the rationale goes in the owning doc` },
|
|
50
|
+
'comment-block-long': { severity: 'warn', message: `a comment block past ${BLOCK_MAX_LINES} lines — one sentence per symbol; a paragraph is a doc, unless it names a booby trap` },
|
|
51
|
+
'comment-narration': { severity: 'warn', message: 'history, a ticket or decision-record reference, or a dated note in a comment — the journey belongs in a record' },
|
|
52
|
+
'comment-shouting': { severity: 'warn', message: 'an ALL-CAPS clause opener — comments are third-person present tense, not emphasis' },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const NARRATION = /\bused to\b|\bno longer\b|\bpreviously\b(?!-)|\bwas (renamed|removed|extracted|folded|migrated|replaced|ported)\b|\bported from\b|\bfor now\b|\bnot yet implemented\b|\b(AD|ADR)-\d+\b|\b20\d{2}-\d{2}-\d{2}\b/i;
|
|
56
|
+
const SHOUTING = /(?:^|[.:;]\s+)([A-Z][A-Z0-9 ,'`\-]{14,})(?=[.,:]|\s+[a-z])/;
|
|
57
|
+
|
|
58
|
+
// The comment token for a path, or null when the format has none.
|
|
59
|
+
function tokenFor(path) {
|
|
60
|
+
return DIALECTS[extname(path)] ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// True when the line is a comment line in this dialect (an open block, a continuation, or a line comment).
|
|
64
|
+
function isCommentLine(line, token) {
|
|
65
|
+
const t = line.trim();
|
|
66
|
+
if (t === '') return false;
|
|
67
|
+
if (token === '//') return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/');
|
|
68
|
+
if (token === '/*') return t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/');
|
|
69
|
+
if (token === '--') return t.startsWith('--') || t.startsWith('/*') || t.startsWith('*');
|
|
70
|
+
return t.startsWith(token);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The leading comment block: its lines, skipping a shebang and any blank lines above it.
|
|
74
|
+
function leadingBlock(lines, token) {
|
|
75
|
+
let i = 0;
|
|
76
|
+
if (lines[0]?.startsWith('#!')) i = 1;
|
|
77
|
+
while (i < lines.length && lines[i].trim() === '') i++;
|
|
78
|
+
const block = [];
|
|
79
|
+
while (i < lines.length && isCommentLine(lines[i], token)) {
|
|
80
|
+
block.push(lines[i]);
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
return block;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Rule ids disabled file-wide (first 20 lines) or on one line.
|
|
87
|
+
function disabledIds(text) {
|
|
88
|
+
const head = text.split('\n').slice(0, 20).join('\n');
|
|
89
|
+
const ids = new Set();
|
|
90
|
+
for (const m of head.matchAll(/source-lint-disable:\s*([a-z0-9,\s-]+)/g))
|
|
91
|
+
for (const id of m[1].split(',')) ids.add(id.trim());
|
|
92
|
+
return ids;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The comment ratio of the lines this change ADDS, or null where there is nothing to grade. An untracked
|
|
96
|
+
// file is added whole; a tracked one is graded on its `+` lines against HEAD. Reading the diff rather than
|
|
97
|
+
// the file is what makes the rule fair: inherited prose is somebody else's row, and a session that adds
|
|
98
|
+
// twenty clean lines to a 60%-comment file passes.
|
|
99
|
+
function addedRatio(path, token) {
|
|
100
|
+
let added;
|
|
101
|
+
try {
|
|
102
|
+
const tracked = spawnSync('git', ['ls-files', '--error-unmatch', path], { encoding: 'utf8' }).status === 0;
|
|
103
|
+
if (!tracked) added = readFileSync(path, 'utf8').split('\n');
|
|
104
|
+
else {
|
|
105
|
+
const out = spawnSync('git', ['diff', '-U0', '--no-color', 'HEAD', '--', path], { encoding: 'utf8' });
|
|
106
|
+
if (out.status !== 0) return null;
|
|
107
|
+
added = out.stdout.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1));
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const nonBlank = added.filter((l) => l.trim() !== '');
|
|
113
|
+
if (nonBlank.length < ADDED_MIN_LINES) return null;
|
|
114
|
+
const comments = nonBlank.filter((l) => isCommentLine(l, token)).length;
|
|
115
|
+
return { ratio: comments / nonBlank.length, comments, lines: nonBlank.length };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function lintFile(path, findings) {
|
|
119
|
+
if (SKIP.test(path)) return false;
|
|
120
|
+
const token = tokenFor(path);
|
|
121
|
+
if (!token) return false;
|
|
122
|
+
let text;
|
|
123
|
+
try {
|
|
124
|
+
text = readFileSync(path, 'utf8');
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (GENERATED.test(text.split('\n').slice(0, 5).join('\n'))) return false;
|
|
129
|
+
|
|
130
|
+
const lines = text.split('\n');
|
|
131
|
+
const disabled = disabledIds(text);
|
|
132
|
+
const add = (id, n, excerpt) => {
|
|
133
|
+
if (disabled.has(id)) return;
|
|
134
|
+
const lineDisable = /source-lint-disable:\s*([a-z0-9,\s-]+)/.exec(lines[n - 1] ?? '');
|
|
135
|
+
if (lineDisable && lineDisable[1].split(',').some((x) => x.trim() === id)) return;
|
|
136
|
+
findings.push({ path, n, id, ...RULES[id], excerpt });
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// --- the header ---------------------------------------------------------------------------------
|
|
140
|
+
const block = leadingBlock(lines, token);
|
|
141
|
+
if (block.length === 0) add('header-missing', 1);
|
|
142
|
+
else {
|
|
143
|
+
const head = block.join('\n');
|
|
144
|
+
// The FIRST path-shaped token, compared exactly — containment would pass a header reading
|
|
145
|
+
// `packages/old-name/src/app.ts` for a file at `src/app.ts`, which is the rename this catches.
|
|
146
|
+
const named = /([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5})\b/.exec(head);
|
|
147
|
+
if (!named) add('header-missing', 1);
|
|
148
|
+
else if (named[1] !== path) {
|
|
149
|
+
if (basename(named[1]) === basename(path)) add('header-path-stale', 1, named[1]);
|
|
150
|
+
else add('header-missing', 1, `names ${named[1]}`);
|
|
151
|
+
}
|
|
152
|
+
if (block.length > HEADER_MAX_LINES) add('header-too-long', 1, `${block.length} lines`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- density, block length, and comment prose --------------------------------------------------
|
|
156
|
+
let commentLines = 0;
|
|
157
|
+
let nonBlank = 0;
|
|
158
|
+
let runStart = 0;
|
|
159
|
+
let run = 0;
|
|
160
|
+
lines.forEach((line, i) => {
|
|
161
|
+
if (line.trim() !== '') nonBlank++;
|
|
162
|
+
if (isCommentLine(line, token)) {
|
|
163
|
+
commentLines++;
|
|
164
|
+
if (run === 0) runStart = i + 1;
|
|
165
|
+
run++;
|
|
166
|
+
const body = line.replace(/^\s*(\/\/+|\/\*+|\*+\/?|--+|#+)\s?/, '');
|
|
167
|
+
if (NARRATION.test(body)) add('comment-narration', i + 1, body.slice(0, 90));
|
|
168
|
+
const shout = SHOUTING.exec(body);
|
|
169
|
+
if (shout) add('comment-shouting', i + 1, shout[1].slice(0, 60));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (run > BLOCK_MAX_LINES) add('comment-block-long', runStart, `${run} lines`);
|
|
173
|
+
run = 0;
|
|
174
|
+
});
|
|
175
|
+
if (run > BLOCK_MAX_LINES) add('comment-block-long', runStart, `${run} lines`);
|
|
176
|
+
if (nonBlank >= RATIO_MIN_LINES) {
|
|
177
|
+
const ratio = commentLines / nonBlank;
|
|
178
|
+
if (ratio > RATIO_CEILING) add('comment-ratio', 1, `${Math.round(ratio * 100)}% of ${nonBlank} lines`);
|
|
179
|
+
}
|
|
180
|
+
const grown = addedRatio(path, token);
|
|
181
|
+
if (grown && grown.ratio > ADDED_RATIO_CEILING)
|
|
182
|
+
add('comment-ratio-added', 1, `${grown.comments} of ${grown.lines} added lines`);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function expandDirs(paths) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const p of paths) {
|
|
189
|
+
if (!existsSync(p)) continue;
|
|
190
|
+
if (!statSync(p).isDirectory()) {
|
|
191
|
+
out.push(p);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const e of readdirSync(p, { withFileTypes: true })) {
|
|
195
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
196
|
+
out.push(...expandDirs([`${p.replace(/\/$/, '')}/${e.name}`]));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const args = process.argv.slice(2);
|
|
203
|
+
let files = args.filter((a) => a !== '--changed');
|
|
204
|
+
if (args.includes('--changed')) {
|
|
205
|
+
const out = execSync('git diff --name-only HEAD; git ls-files --others --exclude-standard', { encoding: 'utf8', shell: '/bin/bash' });
|
|
206
|
+
files = [...new Set([...files, ...out.split('\n').filter(Boolean)])];
|
|
207
|
+
}
|
|
208
|
+
files = expandDirs(files);
|
|
209
|
+
if (!files.length) {
|
|
210
|
+
console.error('usage: node source-lint.mjs <file | dir ...> | --changed');
|
|
211
|
+
process.exit(2);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const findings = [];
|
|
215
|
+
let checked = 0;
|
|
216
|
+
for (const f of files) if (lintFile(f, findings)) checked++;
|
|
217
|
+
|
|
218
|
+
for (const f of findings.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'block' ? -1 : 1)))
|
|
219
|
+
console.log(`${f.path}:${f.n} [${f.severity}] ${f.id} — ${f.message}${f.excerpt ? `: "${f.excerpt}"` : ''}`);
|
|
220
|
+
const blocks = findings.filter((f) => f.severity === 'block').length;
|
|
221
|
+
console.log(`\n${findings.length} finding(s), ${blocks} blocking, ${checked} file(s) checked.`);
|
|
222
|
+
process.exit(blocks ? 1 : 0);
|