@nurix/etna 0.20.15 → 0.20.16

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 (31) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/CLAUDE.md +8 -2
  3. package/data/harnesses/code/agents/bookkeeper.md +1 -0
  4. package/data/harnesses/code/agents/polygraph-finder.md +4 -0
  5. package/data/harnesses/code/harness.json +2 -2
  6. package/data/harnesses/code/hooks/awareness.mjs +15 -2
  7. package/data/harnesses/code/hooks/lib/state.mjs +1 -0
  8. package/data/harnesses/code/hooks/lib/sweep.mjs +20 -2
  9. package/data/harnesses/code/skills/documentation/SKILL.md +28 -0
  10. package/data/harnesses/code/skills/documentation/scripts/comment-strip.mjs +339 -0
  11. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +41 -10
  12. package/data/harnesses/code/skills/documentation/scripts/source-lint.mjs +225 -0
  13. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -2
  14. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
  15. package/data/harnesses/code/skills/polygraph/SKILL.md +1 -1
  16. package/data/harnesses/code/skills/testing/SKILL.md +1 -1
  17. package/data/harnesses/delivery/harness.json +2 -2
  18. package/data/harnesses/delivery/skills/documentation/SKILL.md +28 -0
  19. package/data/harnesses/delivery/skills/documentation/scripts/comment-strip.mjs +339 -0
  20. package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +41 -10
  21. package/data/harnesses/delivery/skills/documentation/scripts/source-lint.mjs +225 -0
  22. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -2
  23. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
  24. package/data/harnesses/etna/harness.json +2 -2
  25. package/data/harnesses/etna/skills/documentation/SKILL.md +28 -0
  26. package/data/harnesses/etna/skills/documentation/scripts/comment-strip.mjs +339 -0
  27. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +41 -10
  28. package/data/harnesses/etna/skills/documentation/scripts/source-lint.mjs +225 -0
  29. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -2
  30. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
  31. package/package.json +2 -2
@@ -0,0 +1,225 @@
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
+ //
6
+ // The convention is the harness `CLAUDE.md` § File Structure and is not restated here: a repo-relative path
7
+ // header of two or three present-tense sentences, one third-person sentence above each non-obvious function,
8
+ // rationale in the owning doc. This script checks only what is mechanical; voice and placement stay judgement.
9
+
10
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
11
+ import { execSync, spawnSync } from 'node:child_process';
12
+ import { basename, extname } from 'node:path';
13
+
14
+ // Comment syntax per extension, and whether a leading `#!` shebang may precede the header.
15
+ const DIALECTS = {
16
+ '.ts': '//', '.tsx': '//', '.mts': '//', '.cts': '//',
17
+ '.js': '//', '.jsx': '//', '.mjs': '//', '.cjs': '//',
18
+ '.rs': '//', '.go': '//', '.java': '//', '.kt': '//', '.swift': '//', '.c': '//', '.h': '//', '.cpp': '//',
19
+ '.css': '/*', '.scss': '//',
20
+ '.sql': '--',
21
+ '.py': '#', '.sh': '#', '.zsh': '#', '.bash': '#', '.rb': '#',
22
+ };
23
+
24
+ // Generated trees, vendored copies, and formats with no comment syntax are a different genre. `.claude/`
25
+ // is harness-owned in every consumer — its files are overwritten on the next roll, so a finding there
26
+ // names work nobody in that repo can land. The forge lints its own copies at `harness/`, not here.
27
+ const SKIP = /(^|\/)(node_modules|dist|build|target|\.git|\.claude|coverage|__screenshots__|data\/harnesses)\/|\.min\.|\.d\.ts$|(^|\/)(pnpm-lock|package-lock)\./;
28
+ // A generated file says so in its own first lines; the convention exempts it.
29
+ const GENERATED = /@generated|DO NOT EDIT|AUTO-GENERATED|This file was generated/i;
30
+ // Below this a file is too small for a density ratio to mean anything.
31
+ const RATIO_MIN_LINES = 40;
32
+ // A file-level ceiling, not a target. The house style sits near 14%; a file past this is carrying prose.
33
+ // It grades INHERITED density, so it only warns: a file somebody else wrote at 42% is not this session's
34
+ // debt, and a wall that blocks on it stops every commit in the repo until a rewrite pass lands.
35
+ const RATIO_CEILING = 0.35;
36
+ // The ceiling that actually holds the line, because it grades only the lines THIS change adds. The measured
37
+ // regression that prompted the rule ran at 28% on new files while the tree sat at 14% — a file-level 35%
38
+ // ceiling passes that cleanly, so the file-level rule cannot be what stops it. You own what you add.
39
+ const ADDED_RATIO_CEILING = 0.25;
40
+ // Below this an added-line ratio is noise — a two-line fix with one comment is not a prose problem.
41
+ const ADDED_MIN_LINES = 20;
42
+ // The header's own ceiling in wrapped lines, from the measured p99 of the convention's own sweep.
43
+ const HEADER_MAX_LINES = 6;
44
+ // A comment block longer than this is a document living in a source file.
45
+ const BLOCK_MAX_LINES = 12;
46
+
47
+ const RULES = {
48
+ 'header-missing': { severity: 'block', message: 'no file-path header comment — add one naming this file\'s repo-relative path' },
49
+ 'header-path-stale': { severity: 'block', message: 'the header names another path — a header a move left behind reads as current' },
50
+ 'header-too-long': { severity: 'warn', message: `header past ${HEADER_MAX_LINES} lines — two or three sentences, and the fuller account goes in its doc` },
51
+ 'comment-ratio': { severity: 'warn', message: `comment lines over ${Math.round(RATIO_CEILING * 100)}% of the file — the rationale belongs in the owning doc` },
52
+ '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` },
53
+ '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` },
54
+ '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' },
55
+ 'comment-shouting': { severity: 'warn', message: 'an ALL-CAPS clause opener — comments are third-person present tense, not emphasis' },
56
+ };
57
+
58
+ 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;
59
+ const SHOUTING = /(?:^|[.:;]\s+)([A-Z][A-Z0-9 ,'`\-]{14,})(?=[.,:]|\s+[a-z])/;
60
+
61
+ // The comment token for a path, or null when the format has none.
62
+ function tokenFor(path) {
63
+ return DIALECTS[extname(path)] ?? null;
64
+ }
65
+
66
+ // True when the line is a comment line in this dialect (an open block, a continuation, or a line comment).
67
+ function isCommentLine(line, token) {
68
+ const t = line.trim();
69
+ if (t === '') return false;
70
+ if (token === '//') return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/');
71
+ if (token === '/*') return t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/');
72
+ if (token === '--') return t.startsWith('--') || t.startsWith('/*') || t.startsWith('*');
73
+ return t.startsWith(token);
74
+ }
75
+
76
+ // The leading comment block: its lines, skipping a shebang and any blank lines above it.
77
+ function leadingBlock(lines, token) {
78
+ let i = 0;
79
+ if (lines[0]?.startsWith('#!')) i = 1;
80
+ while (i < lines.length && lines[i].trim() === '') i++;
81
+ const block = [];
82
+ while (i < lines.length && isCommentLine(lines[i], token)) {
83
+ block.push(lines[i]);
84
+ i++;
85
+ }
86
+ return block;
87
+ }
88
+
89
+ // Rule ids disabled file-wide (first 20 lines) or on one line.
90
+ function disabledIds(text) {
91
+ const head = text.split('\n').slice(0, 20).join('\n');
92
+ const ids = new Set();
93
+ for (const m of head.matchAll(/source-lint-disable:\s*([a-z0-9,\s-]+)/g))
94
+ for (const id of m[1].split(',')) ids.add(id.trim());
95
+ return ids;
96
+ }
97
+
98
+ // The comment ratio of the lines this change ADDS, or null where there is nothing to grade. An untracked
99
+ // file is added whole; a tracked one is graded on its `+` lines against HEAD. Reading the diff rather than
100
+ // the file is what makes the rule fair: inherited prose is somebody else's row, and a session that adds
101
+ // twenty clean lines to a 60%-comment file passes.
102
+ function addedRatio(path, token) {
103
+ let added;
104
+ try {
105
+ const tracked = spawnSync('git', ['ls-files', '--error-unmatch', path], { encoding: 'utf8' }).status === 0;
106
+ if (!tracked) added = readFileSync(path, 'utf8').split('\n');
107
+ else {
108
+ const out = spawnSync('git', ['diff', '-U0', '--no-color', 'HEAD', '--', path], { encoding: 'utf8' });
109
+ if (out.status !== 0) return null;
110
+ added = out.stdout.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1));
111
+ }
112
+ } catch {
113
+ return null;
114
+ }
115
+ const nonBlank = added.filter((l) => l.trim() !== '');
116
+ if (nonBlank.length < ADDED_MIN_LINES) return null;
117
+ const comments = nonBlank.filter((l) => isCommentLine(l, token)).length;
118
+ return { ratio: comments / nonBlank.length, comments, lines: nonBlank.length };
119
+ }
120
+
121
+ function lintFile(path, findings) {
122
+ if (SKIP.test(path)) return false;
123
+ const token = tokenFor(path);
124
+ if (!token) return false;
125
+ let text;
126
+ try {
127
+ text = readFileSync(path, 'utf8');
128
+ } catch {
129
+ return false;
130
+ }
131
+ if (GENERATED.test(text.split('\n').slice(0, 5).join('\n'))) return false;
132
+
133
+ const lines = text.split('\n');
134
+ const disabled = disabledIds(text);
135
+ const add = (id, n, excerpt) => {
136
+ if (disabled.has(id)) return;
137
+ const lineDisable = /source-lint-disable:\s*([a-z0-9,\s-]+)/.exec(lines[n - 1] ?? '');
138
+ if (lineDisable && lineDisable[1].split(',').some((x) => x.trim() === id)) return;
139
+ findings.push({ path, n, id, ...RULES[id], excerpt });
140
+ };
141
+
142
+ // --- the header ---------------------------------------------------------------------------------
143
+ const block = leadingBlock(lines, token);
144
+ if (block.length === 0) add('header-missing', 1);
145
+ else {
146
+ const head = block.join('\n');
147
+ // The FIRST path-shaped token, compared exactly — containment would pass a header reading
148
+ // `packages/old-name/src/app.ts` for a file at `src/app.ts`, which is the rename this catches.
149
+ const named = /([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5})\b/.exec(head);
150
+ if (!named) add('header-missing', 1);
151
+ else if (named[1] !== path) {
152
+ if (basename(named[1]) === basename(path)) add('header-path-stale', 1, named[1]);
153
+ else add('header-missing', 1, `names ${named[1]}`);
154
+ }
155
+ if (block.length > HEADER_MAX_LINES) add('header-too-long', 1, `${block.length} lines`);
156
+ }
157
+
158
+ // --- density, block length, and comment prose --------------------------------------------------
159
+ let commentLines = 0;
160
+ let nonBlank = 0;
161
+ let runStart = 0;
162
+ let run = 0;
163
+ lines.forEach((line, i) => {
164
+ if (line.trim() !== '') nonBlank++;
165
+ if (isCommentLine(line, token)) {
166
+ commentLines++;
167
+ if (run === 0) runStart = i + 1;
168
+ run++;
169
+ const body = line.replace(/^\s*(\/\/+|\/\*+|\*+\/?|--+|#+)\s?/, '');
170
+ if (NARRATION.test(body)) add('comment-narration', i + 1, body.slice(0, 90));
171
+ const shout = SHOUTING.exec(body);
172
+ if (shout) add('comment-shouting', i + 1, shout[1].slice(0, 60));
173
+ return;
174
+ }
175
+ if (run > BLOCK_MAX_LINES) add('comment-block-long', runStart, `${run} lines`);
176
+ run = 0;
177
+ });
178
+ if (run > BLOCK_MAX_LINES) add('comment-block-long', runStart, `${run} lines`);
179
+ if (nonBlank >= RATIO_MIN_LINES) {
180
+ const ratio = commentLines / nonBlank;
181
+ if (ratio > RATIO_CEILING) add('comment-ratio', 1, `${Math.round(ratio * 100)}% of ${nonBlank} lines`);
182
+ }
183
+ const grown = addedRatio(path, token);
184
+ if (grown && grown.ratio > ADDED_RATIO_CEILING)
185
+ add('comment-ratio-added', 1, `${grown.comments} of ${grown.lines} added lines`);
186
+ return true;
187
+ }
188
+
189
+ function expandDirs(paths) {
190
+ const out = [];
191
+ for (const p of paths) {
192
+ if (!existsSync(p)) continue;
193
+ if (!statSync(p).isDirectory()) {
194
+ out.push(p);
195
+ continue;
196
+ }
197
+ for (const e of readdirSync(p, { withFileTypes: true })) {
198
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
199
+ out.push(...expandDirs([`${p.replace(/\/$/, '')}/${e.name}`]));
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+
205
+ const args = process.argv.slice(2);
206
+ let files = args.filter((a) => a !== '--changed');
207
+ if (args.includes('--changed')) {
208
+ const out = execSync('git diff --name-only HEAD; git ls-files --others --exclude-standard', { encoding: 'utf8', shell: '/bin/bash' });
209
+ files = [...new Set([...files, ...out.split('\n').filter(Boolean)])];
210
+ }
211
+ files = expandDirs(files);
212
+ if (!files.length) {
213
+ console.error('usage: node source-lint.mjs <file | dir ...> | --changed');
214
+ process.exit(2);
215
+ }
216
+
217
+ const findings = [];
218
+ let checked = 0;
219
+ for (const f of files) if (lintFile(f, findings)) checked++;
220
+
221
+ for (const f of findings.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'block' ? -1 : 1)))
222
+ console.log(`${f.path}:${f.n} [${f.severity}] ${f.id} — ${f.message}${f.excerpt ? `: "${f.excerpt}"` : ''}`);
223
+ const blocks = findings.filter((f) => f.severity === 'block').length;
224
+ console.log(`\n${findings.length} finding(s), ${blocks} blocking, ${checked} file(s) checked.`);
225
+ process.exit(blocks ? 1 : 0);
@@ -35,7 +35,7 @@ Required: **Conventions**, at least one numbered behavior section, **Failure Rep
35
35
  - **State each section's trace.** `Pinned:` names the suite test that now guards it, or says `driven only` **with the reason** — an unmarked section is indistinguishable from an unexamined one.
36
36
  - **State each section's `AC:` keys.** Where the repo carries a keyed acceptance-criteria source, each section names the keys it proves, so the chain from criterion to checklist to suite test survives the last hop; the gate reports coverage and backlogs uncovered keys. `none — not AC-mapped` is the honest line where no keyed source exists.
37
37
  - **A known-failing assertion stays written as the contract.** A checklist states what the feature promises; the *run* records the failure and the backlog carries the defect. Never soften an assertion to make a run go green.
38
- - **Each driven section carries a dated `Driven` line** — `Driven {YYYY-MM-DD} · PASS|FAIL ({environment}).` plus the evidence the assertion turned on. It is the only thing separating a promise someone verified from a promise someone typed, and its date is what makes staleness visible: a section still carrying last quarter's date next to a surface rewritten since is the file telling you it was not re-driven. `FAIL` is stamped and kept, with the backlog item it raised; a section blocked rather than driven says `NOT DRIVEN — {what blocked it}`.
38
+ - **No section carries a date.** This file is a truth document: it states what must be true and what guards it, and the *event* of driving — the date, the environment, the evidence the assertion turned on, a `FAIL` and the backlog item it raised — is recorded in the changelog entry of the unit that drove it, where a dated line belongs. What separates a promise somebody verified from a promise somebody typed is the `Pinned:` trace, and it is stronger than a stamp because it re-checks itself on every suite run. A section a drive could not reach says `not reachable — {what blocks it}` on its trace line, in the present tense, and keeps saying it until the blocker is gone.
39
39
  - **The file is maintained by whoever drives it.** Nothing else watches it — a checklist owns no code, so no ownership glob implicates it and no hook fires when the surface underneath it changes. Retiring dead sections and repairing traces is part of running the gate, not a separate cleanup someone schedules.
40
40
 
41
41
  ```
@@ -63,7 +63,6 @@ Each step is an action; each **Assert** is the observable it must produce.
63
63
  - [ ] **Assert**: {the observable, with exact literals}
64
64
  - [ ] **Assert**: {the persistence claim, where the behavior writes something}
65
65
 
66
- > Driven {YYYY-MM-DD} · PASS ({environment}). {the evidence — the literal observed, the row that moved}
67
66
  > Pinned: `{tests/path/to/file.test.ts}` — or `driven only — {why no durable test is bought}` — or `none yet — {the test this section owes}`
68
67
  > AC: {the acceptance-criteria keys this section proves} — or `none — not AC-mapped`
69
68
 
@@ -21,4 +21,4 @@ The shared work queue keeping documentation and code in sync — a queue, never
21
21
 
22
22
  **A waiver covers a finding only when it names the finding's kind and its subject in backticks** — the matcher (`waiverCovers`) tests the backticked subject exactly, so a waiver written without them silences nothing and the wall stands. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `dead-glob` waivers double as the dead-glob allowlist; `ownership-overlap` waivers cover the CI ownership check the same way.
23
23
 
24
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`. The CI ownership check additionally accepts `waived:` rows of kind `ownership-overlap`.
24
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited` · `source-lint`. The CI ownership check additionally accepts `waived:` rows of kind `ownership-overlap`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.20.15",
3
+ "version": "0.20.16",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "4dd6abe08c97"
45
+ "etnaFingerprint": "6b7b8d3d3e8f"
46
46
  }