hypomnema 1.2.1 → 1.3.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/docs/CONTRIBUTING.md +96 -11
- package/hooks/hypo-auto-commit.mjs +3 -3
- package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
- package/hooks/hypo-cwd-change.mjs +2 -2
- package/hooks/hypo-first-prompt.mjs +1 -1
- package/hooks/hypo-personal-check.mjs +57 -7
- package/hooks/hypo-session-start.mjs +51 -4
- package/hooks/hypo-shared.mjs +137 -12
- package/hooks/version-check.mjs +204 -6
- package/package.json +5 -2
- package/scripts/bump-version.mjs +9 -3
- package/scripts/check-bilingual.mjs +115 -0
- package/scripts/crystallize.mjs +124 -15
- package/scripts/doctor.mjs +45 -9
- package/scripts/feedback-sync.mjs +44 -15
- package/scripts/feedback.mjs +5 -5
- package/scripts/fix-status-verify.mjs +256 -0
- package/scripts/init.mjs +45 -4
- package/scripts/install-git-hooks.mjs +258 -0
- package/scripts/lib/adr-corpus.mjs +79 -0
- package/scripts/lib/check-bilingual.mjs +141 -0
- package/scripts/lib/extensions.mjs +3 -3
- package/scripts/lib/feedback-scope.mjs +21 -0
- package/scripts/lib/fix-manifest.mjs +109 -0
- package/scripts/lib/fix-status-verify.mjs +438 -0
- package/scripts/lib/pre-commit-format.mjs +251 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lint.mjs +48 -8
- package/scripts/pre-commit-format.mjs +198 -0
- package/scripts/smoke-pack.mjs +16 -0
- package/scripts/upgrade.mjs +55 -23
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fix-status-verify (lib) — pure functions for verifying fix→test linkage.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 of CLAUDE.md learned_behavior #6 (2026-05-16): "merged 표기 전
|
|
5
|
+
* (1) ADR 핵심 결정 라인 grep + (2) replay/integration test green 양쪽 충족".
|
|
6
|
+
* This module automates the *test green* half. ADR core decision grep is out
|
|
7
|
+
* of scope (Phase 2 / v1.3.0 manifest PR).
|
|
8
|
+
*
|
|
9
|
+
* SoT split (after codex 3-worker review 2026-05-27):
|
|
10
|
+
* - fix→test mapping SoT: `// @fix #N: <full test name>` anchor comments in
|
|
11
|
+
* tests/runner.mjs (sits next to the assertion that verifies the fix).
|
|
12
|
+
* - fix status SoT: wiki spec-v1.2.md word-boundary grep
|
|
13
|
+
* (\bfix\s*#N\b ... \b(TRUE_MERGED|merged|resolved)\b).
|
|
14
|
+
*
|
|
15
|
+
* Word-boundary on status terms is required so STALE_MERGED / partial /
|
|
16
|
+
* retired are NOT matched as positive claims.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
20
|
+
|
|
21
|
+
const POSITIVE_STATUSES = new Set(['merged', 'TRUE_MERGED', 'resolved']);
|
|
22
|
+
|
|
23
|
+
// Words that disqualify a line as a positive claim (case-sensitive).
|
|
24
|
+
// STALE_MERGED contains "MERGED" as a substring but is the opposite signal.
|
|
25
|
+
const NEGATIVE_STATUS_TOKENS = ['STALE_MERGED', 'partial', 'retired'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse anchor comments out of runner.mjs source text.
|
|
29
|
+
*
|
|
30
|
+
* // @fix #15: all type-conditional fields present → green
|
|
31
|
+
* // @fix #15: another test name
|
|
32
|
+
*
|
|
33
|
+
* The `@fix` prefix is mandatory — distinguishes anchors from prose comments
|
|
34
|
+
* that mention "fix #N" in passing. Each anchor line maps ONE fix # to ONE
|
|
35
|
+
* test name (whole captured group is the name, no comma-splitting). Multiple
|
|
36
|
+
* anchors for the same fix # accumulate (union, order-preserving, dedupe).
|
|
37
|
+
*
|
|
38
|
+
* Sentinel: NAME = "NO_AUTO_TEST" declares the fix has no automated test by
|
|
39
|
+
* design (behavioral / prompt-driven). Verified upstream in verifyMatrix.
|
|
40
|
+
*/
|
|
41
|
+
export function parseAnchors(runnerText) {
|
|
42
|
+
const out = new Map();
|
|
43
|
+
const re = /^\s*\/\/\s*@fix\s*#(\d+)\s*:\s*(.+?)\s*$/gim;
|
|
44
|
+
let m;
|
|
45
|
+
while ((m = re.exec(runnerText)) !== null) {
|
|
46
|
+
const fixNum = Number(m[1]);
|
|
47
|
+
const name = m[2].trim();
|
|
48
|
+
if (!name) continue;
|
|
49
|
+
if (!out.has(fixNum)) out.set(fixNum, []);
|
|
50
|
+
const list = out.get(fixNum);
|
|
51
|
+
if (!list.includes(name)) list.push(name);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect a redirect-stub spec: a page whose frontmatter declares
|
|
58
|
+
* `type: reference`. These are placeholders left behind after an archive move
|
|
59
|
+
* (e.g. spec-v1.2.md → archive/spec-v1.2.md) and carry zero fix-status claims.
|
|
60
|
+
* Pointing the verifier at one yields a vacuous green, so verifyMatrix rejects
|
|
61
|
+
* it up front.
|
|
62
|
+
*/
|
|
63
|
+
export function isReferenceStub(specText) {
|
|
64
|
+
const fm = parseFrontmatter(specText);
|
|
65
|
+
return fm?.type === 'reference';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse fix status claims out of wiki spec-v1.2.md.
|
|
70
|
+
*
|
|
71
|
+
* Returns Map<fixNum:number, status:string>. status is the most recent
|
|
72
|
+
* positive status token in the file (last mention wins so `merged → resolved`
|
|
73
|
+
* narrative normalises to `resolved`). Fixes whose only mentions are negative
|
|
74
|
+
* (STALE_MERGED / partial / retired) are NOT added — they're considered
|
|
75
|
+
* incomplete claims and skipped by verifyMatrix.
|
|
76
|
+
*/
|
|
77
|
+
export function parseStatus(specText) {
|
|
78
|
+
const out = new Map();
|
|
79
|
+
// For each line, find every fix # mention and check whether a positive
|
|
80
|
+
// status token sits within a small proximity window AFTER the mention. This
|
|
81
|
+
// avoids false positives when a line mentions multiple fix #s with status
|
|
82
|
+
// tokens that only apply to some of them (e.g. "fix #38 (resolved); fix #41
|
|
83
|
+
// (v1.3.0 advisory)" — only #38 should be picked up).
|
|
84
|
+
const lines = specText.split('\n');
|
|
85
|
+
const PROXIMITY = 120; // chars after fix # to scan for status
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
// Quick reject: line must mention a positive status token at all.
|
|
88
|
+
const hasPositive =
|
|
89
|
+
/\bTRUE_MERGED\b/.test(line) ||
|
|
90
|
+
/\bresolved\b/.test(line) ||
|
|
91
|
+
/(?<![A-Z_])merged(?![A-Z_])/.test(line);
|
|
92
|
+
if (!hasPositive) continue;
|
|
93
|
+
// Two accepted fix # forms:
|
|
94
|
+
// (a) inline prose: "fix #N" (word-boundary)
|
|
95
|
+
// (b) table cell start: "| #N |" (§9.1.0 status correction table)
|
|
96
|
+
const matches = [];
|
|
97
|
+
let m2;
|
|
98
|
+
const inlineRe = /\bfix\s*#(\d+)\b/gi;
|
|
99
|
+
while ((m2 = inlineRe.exec(line)) !== null) {
|
|
100
|
+
matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
|
|
101
|
+
}
|
|
102
|
+
const tableRe = /\|\s*#(\d+)\s*\|/g;
|
|
103
|
+
while ((m2 = tableRe.exec(line)) !== null) {
|
|
104
|
+
matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
|
|
105
|
+
}
|
|
106
|
+
for (const { fixNum, end } of matches) {
|
|
107
|
+
const window = line.slice(end, end + PROXIMITY);
|
|
108
|
+
// Determine the strongest status in the proximity window. Priority:
|
|
109
|
+
// TRUE_MERGED > resolved > merged.
|
|
110
|
+
let status = null;
|
|
111
|
+
if (/\bTRUE_MERGED\b/.test(window)) status = 'TRUE_MERGED';
|
|
112
|
+
else if (/\bresolved\b/.test(window)) status = 'resolved';
|
|
113
|
+
else if (/(?<![A-Z_])merged(?![A-Z_])/.test(window)) status = 'merged';
|
|
114
|
+
if (status) out.set(fixNum, status); // last positive mention wins
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse runner.mjs stdout to map test names → "pass" | "fail".
|
|
122
|
+
*
|
|
123
|
+
* The harness prints ` ✓ <name>` on pass and ` ✗ <name>` on fail.
|
|
124
|
+
*/
|
|
125
|
+
export function parseRunnerOutput(stdout) {
|
|
126
|
+
const out = new Map();
|
|
127
|
+
const lines = stdout.split('\n');
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
const passM = line.match(/^\s*✓\s+(.+?)\s*$/);
|
|
130
|
+
if (passM) {
|
|
131
|
+
// Sticky pass — only set if no prior result. A later fail must NOT be
|
|
132
|
+
// overridden, and a prior fail must not be flipped back to pass.
|
|
133
|
+
if (!out.has(passM[1])) out.set(passM[1], 'pass');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const failM = line.match(/^\s*✗\s+(.+?)\s*$/);
|
|
137
|
+
if (failM) {
|
|
138
|
+
// Fail is sticky: once a name has any failure, the verdict stays fail
|
|
139
|
+
// even if a duplicate test() with the same name passed elsewhere.
|
|
140
|
+
out.set(failM[1], 'fail');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Cross-check anchors × status × test results.
|
|
148
|
+
*
|
|
149
|
+
* Finding classes:
|
|
150
|
+
* NO_ANCHOR — fix claimed positive in spec, no anchor in runner.
|
|
151
|
+
* MISSING_TEST — anchor names a test, runner output does not contain it.
|
|
152
|
+
* FAILING_TEST — anchor names a test, runner output marks it failed.
|
|
153
|
+
* ORPHAN_ANCHOR — anchor exists, no positive status claim in spec (warn).
|
|
154
|
+
* STUB_SPEC — spec is unusable: a `type: reference` redirect stub, or
|
|
155
|
+
* it parses zero positive status claims while anchors exist
|
|
156
|
+
* (the vacuous-gate the tool exists to prevent). Error.
|
|
157
|
+
*
|
|
158
|
+
* Returns { ok, findings: [...] }. ok=false if any ERROR-level finding.
|
|
159
|
+
* ORPHAN_ANCHOR is WARN-only.
|
|
160
|
+
*
|
|
161
|
+
* STUB_SPEC is a precondition failure, so it short-circuits: when the spec is
|
|
162
|
+
* unusable there is nothing meaningful to cross-check, and the per-anchor
|
|
163
|
+
* ORPHAN noise would only bury the one decisive error.
|
|
164
|
+
*/
|
|
165
|
+
export function verifyMatrix({ anchors, status, testResults, specIsStub = false }) {
|
|
166
|
+
if (specIsStub) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
findings: [
|
|
170
|
+
{
|
|
171
|
+
level: 'error',
|
|
172
|
+
class: 'STUB_SPEC',
|
|
173
|
+
detail:
|
|
174
|
+
'spec is a `type: reference` redirect stub (0 fix-status claims by design) — pass --spec pointing at the real spec',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Vacuous-gate invariant: anchors exist in the runner but the spec yields no
|
|
180
|
+
// positive status claim to verify them against. Greening here would defeat
|
|
181
|
+
// the tool's purpose. (No anchors + no claims is an empty/custom matrix, not
|
|
182
|
+
// a vacuous gate, so it is left to the normal path.)
|
|
183
|
+
if (status.size === 0 && anchors.size > 0) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
findings: [
|
|
187
|
+
{
|
|
188
|
+
level: 'error',
|
|
189
|
+
class: 'STUB_SPEC',
|
|
190
|
+
detail: `${anchors.size} anchor(s) in runner but 0 positive status claims parsed from spec — gate would be vacuous`,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const findings = [];
|
|
197
|
+
|
|
198
|
+
for (const [fixNum, statusValue] of status.entries()) {
|
|
199
|
+
const anchored = anchors.get(fixNum);
|
|
200
|
+
if (!anchored || anchored.length === 0) {
|
|
201
|
+
findings.push({
|
|
202
|
+
level: 'error',
|
|
203
|
+
class: 'NO_ANCHOR',
|
|
204
|
+
fixNum,
|
|
205
|
+
status: statusValue,
|
|
206
|
+
detail: `claimed ${statusValue} in spec but no // fix #${fixNum}: anchor in runner.mjs`,
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// Sentinel: explicit "no automated test by design" (behavioral /
|
|
211
|
+
// prompt-driven fixes). The fix is still claimed-merged but verifying it
|
|
212
|
+
// is out of scope for an integration runner.
|
|
213
|
+
if (anchored.length === 1 && anchored[0] === 'NO_AUTO_TEST') {
|
|
214
|
+
findings.push({
|
|
215
|
+
level: 'info',
|
|
216
|
+
class: 'NO_AUTO_TEST',
|
|
217
|
+
fixNum,
|
|
218
|
+
status: statusValue,
|
|
219
|
+
detail: `fix #${fixNum} declares NO_AUTO_TEST (behavioral / prompt-driven)`,
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const testName of anchored) {
|
|
224
|
+
const result = testResults.get(testName);
|
|
225
|
+
if (result === undefined) {
|
|
226
|
+
findings.push({
|
|
227
|
+
level: 'error',
|
|
228
|
+
class: 'MISSING_TEST',
|
|
229
|
+
fixNum,
|
|
230
|
+
status: statusValue,
|
|
231
|
+
testName,
|
|
232
|
+
detail: `anchor names "${testName}" but no such test ran`,
|
|
233
|
+
});
|
|
234
|
+
} else if (result === 'fail') {
|
|
235
|
+
findings.push({
|
|
236
|
+
level: 'error',
|
|
237
|
+
class: 'FAILING_TEST',
|
|
238
|
+
fixNum,
|
|
239
|
+
status: statusValue,
|
|
240
|
+
testName,
|
|
241
|
+
detail: `test "${testName}" failed`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const [fixNum, names] of anchors.entries()) {
|
|
248
|
+
if (!status.has(fixNum)) {
|
|
249
|
+
findings.push({
|
|
250
|
+
level: 'warn',
|
|
251
|
+
class: 'ORPHAN_ANCHOR',
|
|
252
|
+
fixNum,
|
|
253
|
+
tests: names,
|
|
254
|
+
detail: `anchor exists for fix #${fixNum} but no positive status claim in spec`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const ok = !findings.some((f) => f.level === 'error');
|
|
260
|
+
return { ok, findings };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Phase 2 (A-sot) — manifest validation, coverage/drift, ADR-line grep ─────
|
|
264
|
+
// ADR 0036: manifest is the evidence SoT (fix → test + ADR-line). status SoT
|
|
265
|
+
// stays in the spec. These are pure functions; the CLI injects fs-backed
|
|
266
|
+
// searchFn / adrExistsFn so the corpus walk stays out of the pure layer.
|
|
267
|
+
|
|
268
|
+
import { FIX_MANIFEST, NO_ADR, NO_AUTO_TEST } from './fix-manifest.mjs';
|
|
269
|
+
|
|
270
|
+
/** Order-insensitive set equality over string arrays (deduped). */
|
|
271
|
+
function sameStringSet(a, b) {
|
|
272
|
+
const sa = new Set(a);
|
|
273
|
+
const sb = new Set(b);
|
|
274
|
+
if (sa.size !== sb.size) return false;
|
|
275
|
+
for (const x of sa) if (!sb.has(x)) return false;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Structural validation of the manifest shape (ADR 0036).
|
|
281
|
+
*
|
|
282
|
+
* Findings (all error):
|
|
283
|
+
* MANIFEST_DUP_FIXID — two rows share a fixId.
|
|
284
|
+
* MANIFEST_EMPTY_TESTS — testNames is empty.
|
|
285
|
+
* MANIFEST_SENTINEL_MIX — NO_AUTO_TEST mixed with real test names.
|
|
286
|
+
* MANIFEST_EMPTY_KEYLINE — adrKeyLine missing/blank.
|
|
287
|
+
* MANIFEST_NO_ADR_SHAPE — NO_ADR row with non-null adrPath, or a non-NO_ADR
|
|
288
|
+
* row with null adrPath.
|
|
289
|
+
*/
|
|
290
|
+
export function validateManifest(manifest = FIX_MANIFEST) {
|
|
291
|
+
const findings = [];
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
for (const row of manifest) {
|
|
294
|
+
const fixNum = row.fixId;
|
|
295
|
+
if (seen.has(fixNum)) {
|
|
296
|
+
findings.push({
|
|
297
|
+
level: 'error',
|
|
298
|
+
class: 'MANIFEST_DUP_FIXID',
|
|
299
|
+
fixNum,
|
|
300
|
+
detail: `duplicate manifest row for fix #${fixNum}`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
seen.add(fixNum);
|
|
304
|
+
|
|
305
|
+
const names = Array.isArray(row.testNames) ? row.testNames : [];
|
|
306
|
+
if (names.length === 0) {
|
|
307
|
+
findings.push({
|
|
308
|
+
level: 'error',
|
|
309
|
+
class: 'MANIFEST_EMPTY_TESTS',
|
|
310
|
+
fixNum,
|
|
311
|
+
detail: `fix #${fixNum} manifest row has empty testNames`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (names.includes(NO_AUTO_TEST) && names.length > 1) {
|
|
315
|
+
findings.push({
|
|
316
|
+
level: 'error',
|
|
317
|
+
class: 'MANIFEST_SENTINEL_MIX',
|
|
318
|
+
fixNum,
|
|
319
|
+
detail: `fix #${fixNum} mixes NO_AUTO_TEST sentinel with real test names`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const keyLine = typeof row.adrKeyLine === 'string' ? row.adrKeyLine.trim() : '';
|
|
324
|
+
if (!keyLine) {
|
|
325
|
+
findings.push({
|
|
326
|
+
level: 'error',
|
|
327
|
+
class: 'MANIFEST_EMPTY_KEYLINE',
|
|
328
|
+
fixNum,
|
|
329
|
+
detail: `fix #${fixNum} manifest row has empty adrKeyLine`,
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const isNoAdr = row.adrKeyLine === NO_ADR;
|
|
334
|
+
if (isNoAdr && row.adrPath != null) {
|
|
335
|
+
findings.push({
|
|
336
|
+
level: 'error',
|
|
337
|
+
class: 'MANIFEST_NO_ADR_SHAPE',
|
|
338
|
+
fixNum,
|
|
339
|
+
detail: `fix #${fixNum} is NO_ADR but adrPath is not null`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (!isNoAdr && row.adrPath == null) {
|
|
343
|
+
findings.push({
|
|
344
|
+
level: 'error',
|
|
345
|
+
class: 'MANIFEST_NO_ADR_SHAPE',
|
|
346
|
+
fixNum,
|
|
347
|
+
detail: `fix #${fixNum} has a real adrKeyLine but null adrPath`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return findings;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Coverage + drift between manifest, runner anchors, and spec status claims.
|
|
356
|
+
*
|
|
357
|
+
* MANIFEST_MISSING_ROW — a fix claimed-merged AND anchored has no manifest
|
|
358
|
+
* row (its ADR-line check would be silently skipped).
|
|
359
|
+
* Error: a missing row bypasses the whole gate.
|
|
360
|
+
* MANIFEST_TEST_DRIFT — a manifest row's testNames do not set-equal the
|
|
361
|
+
* runner anchors for that fix (stale evidence).
|
|
362
|
+
*
|
|
363
|
+
* Both error-level. The claimed∩anchored requirement mirrors the manifest
|
|
364
|
+
* scope (ADR 0036): rows exist to prove claims; anchors-without-claims are
|
|
365
|
+
* ORPHAN_ANCHOR (handled in verifyMatrix), not manifest gaps.
|
|
366
|
+
*/
|
|
367
|
+
export function checkManifestCoverage({ manifest = FIX_MANIFEST, anchors, status }) {
|
|
368
|
+
const findings = [];
|
|
369
|
+
const byFix = new Map(manifest.map((r) => [r.fixId, r]));
|
|
370
|
+
|
|
371
|
+
for (const fixNum of status.keys()) {
|
|
372
|
+
if (!anchors.has(fixNum)) continue; // claimed but unanchored → NO_ANCHOR (verifyMatrix)
|
|
373
|
+
if (!byFix.has(fixNum)) {
|
|
374
|
+
findings.push({
|
|
375
|
+
level: 'error',
|
|
376
|
+
class: 'MANIFEST_MISSING_ROW',
|
|
377
|
+
fixNum,
|
|
378
|
+
detail: `fix #${fixNum} is claimed-merged and anchored but has no manifest row`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const row of manifest) {
|
|
384
|
+
const fixNum = row.fixId;
|
|
385
|
+
const anchored = anchors.get(fixNum) || [];
|
|
386
|
+
const names = Array.isArray(row.testNames) ? row.testNames : [];
|
|
387
|
+
if (!sameStringSet(names, anchored)) {
|
|
388
|
+
findings.push({
|
|
389
|
+
level: 'error',
|
|
390
|
+
class: 'MANIFEST_TEST_DRIFT',
|
|
391
|
+
fixNum,
|
|
392
|
+
detail:
|
|
393
|
+
`fix #${fixNum} manifest testNames ${JSON.stringify(names)} ` +
|
|
394
|
+
`≠ runner anchors ${JSON.stringify(anchored)}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return findings;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* ADR-line grep: each non-NO_ADR manifest row must point at an existing ADR
|
|
403
|
+
* file and its adrKeyLine must exist verbatim in the production-code corpus.
|
|
404
|
+
*
|
|
405
|
+
* ADR_PATH_MISSING — adrPath does not resolve to a file.
|
|
406
|
+
* ADR_LINE_MISSING — adrKeyLine not found in the corpus (fixed-string).
|
|
407
|
+
*
|
|
408
|
+
* searchFn(literal) → boolean: true iff the literal appears in the corpus
|
|
409
|
+
* (the corpus MUST exclude scripts/lib/fix-manifest.mjs, else every line
|
|
410
|
+
* self-matches and the gate is vacuous — see the CLI corpus builder).
|
|
411
|
+
* adrExistsFn(adrPath) → boolean. NO_ADR rows are skipped (test-green only).
|
|
412
|
+
*/
|
|
413
|
+
export function checkAdrLines({ manifest = FIX_MANIFEST, searchFn, adrExistsFn }) {
|
|
414
|
+
const findings = [];
|
|
415
|
+
for (const row of manifest) {
|
|
416
|
+
if (row.adrKeyLine === NO_ADR) continue;
|
|
417
|
+
const fixNum = row.fixId;
|
|
418
|
+
if (row.adrPath != null && !adrExistsFn(row.adrPath)) {
|
|
419
|
+
findings.push({
|
|
420
|
+
level: 'error',
|
|
421
|
+
class: 'ADR_PATH_MISSING',
|
|
422
|
+
fixNum,
|
|
423
|
+
detail: `fix #${fixNum} adrPath does not resolve: ${row.adrPath}`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (!searchFn(row.adrKeyLine)) {
|
|
427
|
+
findings.push({
|
|
428
|
+
level: 'error',
|
|
429
|
+
class: 'ADR_LINE_MISSING',
|
|
430
|
+
fixNum,
|
|
431
|
+
detail: `fix #${fixNum} adrKeyLine not found in production corpus: "${row.adrKeyLine}"`,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return findings;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export { POSITIVE_STATUSES, NEGATIVE_STATUS_TOKENS, FIX_MANIFEST, NO_ADR, NO_AUTO_TEST };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/pre-commit-format.mjs — pure logic for the auto-format-on-commit hook.
|
|
3
|
+
*
|
|
4
|
+
* Rule source: CLAUDE.md <formatting> directive. Pre-commit hook auto-runs the
|
|
5
|
+
* project formatter on STAGED files only. Formatter failure is non-blocking;
|
|
6
|
+
* only `git add` failure on restage is a true commit block.
|
|
7
|
+
*
|
|
8
|
+
* Why pure: lets tests construct synthetic staged sets without touching real
|
|
9
|
+
* git or invoking prettier. The CLI shim in scripts/pre-commit-format.mjs
|
|
10
|
+
* handles env resolution / repo-identity guards / process exit codes.
|
|
11
|
+
*
|
|
12
|
+
* Env discipline: every `git` spawn here MUST receive a caller-supplied `env`.
|
|
13
|
+
* The lib never reads `process.env` for git operations — that defence is what
|
|
14
|
+
* blocks GIT_DIR / GIT_WORK_TREE override attacks (see CONTRIBUTING.md).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const STATUS_FILTER = 'ACMR';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse the NUL-token stream emitted by `git diff --cached --name-status -z`.
|
|
25
|
+
*
|
|
26
|
+
* Token shape (verified live against git 2.50): records are NUL-separated.
|
|
27
|
+
* A\0path\0 (added)
|
|
28
|
+
* M\0path\0 (modified)
|
|
29
|
+
* D\0path\0 (deleted — filtered out by --diff-filter)
|
|
30
|
+
* T\0path\0 (type change — filtered out)
|
|
31
|
+
* R<score>\0old\0new\0 (rename)
|
|
32
|
+
* C<score>\0old\0new\0 (copy)
|
|
33
|
+
*
|
|
34
|
+
* Paths containing TAB are valid — TAB is not a separator here (it appears in
|
|
35
|
+
* the non-`-z` output, never in `-z`). Only NUL separates records.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} buf Raw stdout from `git diff --cached --name-status -z`.
|
|
38
|
+
* @returns {Array<{path: string, status: string}>}
|
|
39
|
+
*/
|
|
40
|
+
export function parseNameStatus(buf) {
|
|
41
|
+
const tokens = buf.split('\0');
|
|
42
|
+
// Trailing NUL leaves an empty token; drop it (and any stray empties).
|
|
43
|
+
while (tokens.length && tokens[tokens.length - 1] === '') tokens.pop();
|
|
44
|
+
const out = [];
|
|
45
|
+
for (let i = 0; i < tokens.length; ) {
|
|
46
|
+
const status = tokens[i++];
|
|
47
|
+
if (!status) continue;
|
|
48
|
+
const head = status[0];
|
|
49
|
+
if (head === 'R' || head === 'C') {
|
|
50
|
+
// Two-path record. Old is irrelevant for formatting — only the new path
|
|
51
|
+
// exists in the staged tree.
|
|
52
|
+
i++; // consume old
|
|
53
|
+
const next = tokens[i++];
|
|
54
|
+
if (next) out.push({ path: next, status: head });
|
|
55
|
+
} else if (head === 'A' || head === 'M') {
|
|
56
|
+
const p = tokens[i++];
|
|
57
|
+
if (p) out.push({ path: p, status: head });
|
|
58
|
+
} else {
|
|
59
|
+
// D, T, U, X — consume one path, drop. --diff-filter should exclude
|
|
60
|
+
// these but we defensively skip.
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse `git ls-files --stage -z` output to map paths → file mode strings.
|
|
69
|
+
* Output shape: `<mode> <hash> <stage>\t<path>\0`
|
|
70
|
+
*/
|
|
71
|
+
export function parseLsFilesStage(buf) {
|
|
72
|
+
const map = new Map();
|
|
73
|
+
const records = buf.split('\0');
|
|
74
|
+
for (const rec of records) {
|
|
75
|
+
if (!rec) continue;
|
|
76
|
+
const tabIdx = rec.indexOf('\t');
|
|
77
|
+
if (tabIdx < 0) continue;
|
|
78
|
+
const meta = rec.slice(0, tabIdx);
|
|
79
|
+
const path = rec.slice(tabIdx + 1);
|
|
80
|
+
const mode = meta.split(' ')[0];
|
|
81
|
+
map.set(path, mode);
|
|
82
|
+
}
|
|
83
|
+
return map;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drop symlinks (120000) and gitlinks/submodules (160000). Regular file modes
|
|
88
|
+
* (100644, 100755) are kept.
|
|
89
|
+
*/
|
|
90
|
+
export function filterRegularFiles(entries, modeMap) {
|
|
91
|
+
return entries.filter((e) => {
|
|
92
|
+
const m = modeMap.get(e.path);
|
|
93
|
+
if (!m) return false; // not in index — defensively skip
|
|
94
|
+
return m !== '120000' && m !== '160000';
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Partition staged paths into safe vs partial (also has unstaged hunks).
|
|
100
|
+
* Partial files are skipped to avoid swallowing unstaged work.
|
|
101
|
+
*/
|
|
102
|
+
export function partitionStagedFiles(entries, unstagedDirty) {
|
|
103
|
+
const safe = [];
|
|
104
|
+
const partial = [];
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
if (unstagedDirty.has(e.path)) partial.push(e);
|
|
107
|
+
else safe.push(e);
|
|
108
|
+
}
|
|
109
|
+
return { safe, partial };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Formatter dispatch table. Other entries (eslint, black, gofmt, cargo fmt)
|
|
114
|
+
* are placeholders — the table is data, not a branch tree. Activate by
|
|
115
|
+
* filling in an entry similar to `prettier`.
|
|
116
|
+
*
|
|
117
|
+
* Critically: NEVER use `npx` here. `npx prettier` may try a network install
|
|
118
|
+
* on a cold machine; we want a local-only binary or no-op.
|
|
119
|
+
*/
|
|
120
|
+
export function selectFormatter(repoRoot) {
|
|
121
|
+
const prettierBin = join(repoRoot, 'node_modules', '.bin', 'prettier');
|
|
122
|
+
if (existsSync(prettierBin)) {
|
|
123
|
+
return {
|
|
124
|
+
name: 'prettier',
|
|
125
|
+
bin: prettierBin,
|
|
126
|
+
buildArgs: (files) => ['--write', '--', ...files],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run the formatter once with all safe files. Captures exit status; never
|
|
134
|
+
* throws.
|
|
135
|
+
*/
|
|
136
|
+
export function formatFiles(safe, formatter, { env, cwd } = {}) {
|
|
137
|
+
if (!safe.length || !formatter) {
|
|
138
|
+
return { ran: false, formatterFailed: false, reason: 'noop' };
|
|
139
|
+
}
|
|
140
|
+
const paths = safe.map((e) => e.path);
|
|
141
|
+
const res = spawnSync(formatter.bin, formatter.buildArgs(paths), {
|
|
142
|
+
cwd,
|
|
143
|
+
env,
|
|
144
|
+
encoding: 'utf-8',
|
|
145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
ran: true,
|
|
149
|
+
formatterFailed: res.status !== 0,
|
|
150
|
+
exitCode: res.status,
|
|
151
|
+
stdout: res.stdout || '',
|
|
152
|
+
stderr: res.stderr || '',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Re-stage the formatted files. Returns `{gitAddFailed: bool, stderr}`.
|
|
158
|
+
* Prettier `--write` only writes on actual content change, so re-adding
|
|
159
|
+
* unchanged files is a cheap no-op. Doing it unconditionally avoids a
|
|
160
|
+
* before/after hash comparison.
|
|
161
|
+
*/
|
|
162
|
+
export function restageFormatted(files, { env, cwd } = {}) {
|
|
163
|
+
if (!files.length) return { gitAddFailed: false };
|
|
164
|
+
const res = spawnSync('git', ['add', '--', ...files], {
|
|
165
|
+
cwd,
|
|
166
|
+
env,
|
|
167
|
+
encoding: 'utf-8',
|
|
168
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
gitAddFailed: res.status !== 0,
|
|
172
|
+
stderr: res.stderr || '',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Top-level orchestrator used by the CLI shim. The caller supplies `cwd`
|
|
178
|
+
* (the Hypomnema toplevel) and a sanitized `env` (no inherited `GIT_*`
|
|
179
|
+
* except optionally a validated `GIT_INDEX_FILE`).
|
|
180
|
+
*
|
|
181
|
+
* @returns {{gitAddFailed: boolean, summary: string}}
|
|
182
|
+
*/
|
|
183
|
+
export async function runPreCommitFormat({ cwd, env }) {
|
|
184
|
+
const summary = [];
|
|
185
|
+
const stagedRes = spawnSync(
|
|
186
|
+
'git',
|
|
187
|
+
['diff', '--cached', '--name-status', '-z', `--diff-filter=${STATUS_FILTER}`, '--'],
|
|
188
|
+
{ cwd, env, encoding: 'utf-8' },
|
|
189
|
+
);
|
|
190
|
+
if (stagedRes.status !== 0) {
|
|
191
|
+
return { gitAddFailed: false, summary: 'git diff --cached failed; skipping' };
|
|
192
|
+
}
|
|
193
|
+
const staged = parseNameStatus(stagedRes.stdout || '');
|
|
194
|
+
if (!staged.length) return { gitAddFailed: false, summary: 'no staged files' };
|
|
195
|
+
|
|
196
|
+
// Filter out symlinks / submodules.
|
|
197
|
+
const lsRes = spawnSync(
|
|
198
|
+
'git',
|
|
199
|
+
['ls-files', '--stage', '-z', '--', ...staged.map((e) => e.path)],
|
|
200
|
+
{ cwd, env, encoding: 'utf-8' },
|
|
201
|
+
);
|
|
202
|
+
let regular = staged;
|
|
203
|
+
if (lsRes.status === 0) {
|
|
204
|
+
const modeMap = parseLsFilesStage(lsRes.stdout || '');
|
|
205
|
+
regular = filterRegularFiles(staged, modeMap);
|
|
206
|
+
}
|
|
207
|
+
if (!regular.length) return { gitAddFailed: false, summary: 'no regular staged files' };
|
|
208
|
+
|
|
209
|
+
// Unstaged-dirty set for partition.
|
|
210
|
+
const unstRes = spawnSync('git', ['diff', '--name-only', '-z', '--'], {
|
|
211
|
+
cwd,
|
|
212
|
+
env,
|
|
213
|
+
encoding: 'utf-8',
|
|
214
|
+
});
|
|
215
|
+
const unstaged = new Set();
|
|
216
|
+
if (unstRes.status === 0) {
|
|
217
|
+
for (const p of (unstRes.stdout || '').split('\0')) {
|
|
218
|
+
if (p) unstaged.add(p);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const { safe, partial } = partitionStagedFiles(regular, unstaged);
|
|
222
|
+
if (partial.length) {
|
|
223
|
+
summary.push(`skipped ${partial.length} partially-staged file(s)`);
|
|
224
|
+
}
|
|
225
|
+
if (!safe.length) return { gitAddFailed: false, summary: summary.join('; ') || 'no safe files' };
|
|
226
|
+
|
|
227
|
+
const formatter = selectFormatter(cwd);
|
|
228
|
+
if (!formatter) {
|
|
229
|
+
return {
|
|
230
|
+
gitAddFailed: false,
|
|
231
|
+
summary: [...summary, 'no formatter (node_modules/.bin/prettier missing)'].join('; '),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const fmt = formatFiles(safe, formatter, { env, cwd });
|
|
235
|
+
if (fmt.formatterFailed) {
|
|
236
|
+
summary.push(`${formatter.name} exit ${fmt.exitCode} (non-blocking)`);
|
|
237
|
+
return { gitAddFailed: false, summary: summary.join('; ') };
|
|
238
|
+
}
|
|
239
|
+
const restage = restageFormatted(
|
|
240
|
+
safe.map((e) => e.path),
|
|
241
|
+
{ env, cwd },
|
|
242
|
+
);
|
|
243
|
+
if (restage.gitAddFailed) {
|
|
244
|
+
return {
|
|
245
|
+
gitAddFailed: true,
|
|
246
|
+
summary: `git add failed: ${restage.stderr.trim()}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
summary.push(`formatted ${safe.length} file(s) via ${formatter.name}`);
|
|
250
|
+
return { gitAddFailed: false, summary: summary.join('; ') };
|
|
251
|
+
}
|
|
@@ -64,8 +64,8 @@ export function insertHotRow(content, name, today) {
|
|
|
64
64
|
if (content.includes(link)) return content; // already present
|
|
65
65
|
const lines = content.split('\n');
|
|
66
66
|
// Scope the search to the "## Active Projects" section so a table appearing
|
|
67
|
-
// earlier in hot.md can't capture the row
|
|
68
|
-
//
|
|
67
|
+
// earlier in hot.md can't capture the row. Start looking from the heading;
|
|
68
|
+
// stop at the next H2 so we never cross sections.
|
|
69
69
|
const headingIdx = lines.findIndex((l) => /^##\s+Active Projects\s*$/.test(l));
|
|
70
70
|
if (headingIdx === -1) return null;
|
|
71
71
|
let sepIdx = -1;
|