hypomnema 1.4.2 → 1.5.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +35 -18
- package/README.md +25 -8
- package/commands/audit.md +2 -2
- package/commands/crystallize.md +22 -14
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +4 -2
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/CONTRIBUTING.md +8 -7
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- package/hooks/hypo-lookup.mjs +37 -2
- package/hooks/hypo-personal-check.mjs +37 -22
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-record.mjs +2 -2
- package/hooks/hypo-session-start.mjs +62 -42
- package/hooks/hypo-shared.mjs +430 -85
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +151 -53
- package/scripts/doctor.mjs +7 -7
- package/scripts/feedback-sync.mjs +5 -5
- package/scripts/feedback.mjs +9 -10
- package/scripts/init.mjs +7 -7
- package/scripts/lib/check-tracker-ids.mjs +90 -32
- package/scripts/lib/design-history-stale.mjs +1 -1
- package/scripts/lib/extensions.mjs +7 -7
- package/scripts/lib/failure-type.mjs +1 -1
- package/scripts/lib/feedback-scope.mjs +1 -1
- package/scripts/lib/page-usage.mjs +141 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lib/template-schema-version.mjs +1 -1
- package/scripts/lib/wd-match.mjs +181 -0
- package/scripts/lib/wikilink.mjs +1 -1
- package/scripts/lint.mjs +5 -5
- package/scripts/rename.mjs +1 -1
- package/scripts/resume.mjs +20 -22
- package/scripts/session-audit.mjs +1 -1
- package/scripts/stats.mjs +3 -3
- package/scripts/uninstall.mjs +3 -3
- package/scripts/upgrade.mjs +85 -18
- package/skills/crystallize/SKILL.md +17 -11
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +5 -5
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/SCHEMA.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +21 -15
- package/templates/projects/_template/hot.md +1 -1
- package/scripts/fix-status-verify.mjs +0 -256
- package/scripts/lib/adr-corpus.mjs +0 -79
- package/scripts/lib/fix-manifest.mjs +0 -109
- package/scripts/lib/fix-status-verify.mjs +0 -439
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* adr-corpus — fs-backed production-code corpus search for the ADR-line grep.
|
|
3
|
-
*
|
|
4
|
-
* Kept separate from the pure verifier (scripts/lib/fix-status-verify.mjs) so
|
|
5
|
-
* that layer stays IO-free and unit-testable with injected searchFns. This
|
|
6
|
-
* module is itself testable against real temp directories.
|
|
7
|
-
*
|
|
8
|
-
* CRITICAL (self-match): the manifest module (scripts/lib/fix-manifest.mjs)
|
|
9
|
-
* lives *inside* the scripts/ corpus and holds every adrKeyLine as a literal.
|
|
10
|
-
* If it were scanned, every line would self-match and ADR_LINE_MISSING could
|
|
11
|
-
* never fire — the gate would be silently vacuous. The builder therefore
|
|
12
|
-
* excludes caller-supplied paths, resolved absolute, BEFORE reading any file.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
-
import { join, resolve } from 'node:path';
|
|
17
|
-
|
|
18
|
-
const DEFAULT_EXTENSIONS = ['.mjs', '.js', '.md', '.json', '.cjs'];
|
|
19
|
-
|
|
20
|
-
function* walk(dir, excludeAbs, extensions) {
|
|
21
|
-
let entries;
|
|
22
|
-
try {
|
|
23
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
24
|
-
} catch {
|
|
25
|
-
return; // missing corpus dir is not fatal — other dirs may exist
|
|
26
|
-
}
|
|
27
|
-
for (const ent of entries) {
|
|
28
|
-
const full = join(dir, ent.name);
|
|
29
|
-
if (excludeAbs.has(resolve(full))) continue;
|
|
30
|
-
if (ent.isDirectory()) {
|
|
31
|
-
if (ent.name === 'node_modules' || ent.name === '.git') continue;
|
|
32
|
-
yield* walk(full, excludeAbs, extensions);
|
|
33
|
-
} else if (ent.isFile()) {
|
|
34
|
-
if (extensions.some((e) => ent.name.endsWith(e))) yield full;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Build a fixed-string corpus search function.
|
|
41
|
-
*
|
|
42
|
-
* buildCorpusSearch({ repoRoot, includeDirs, excludePaths, extensions })
|
|
43
|
-
* → (literal:string) => boolean
|
|
44
|
-
*
|
|
45
|
-
* - includeDirs / excludePaths are resolved relative to repoRoot.
|
|
46
|
-
* - excludePaths are matched by resolved absolute path (handles symlinks of the
|
|
47
|
-
* caller-supplied path consistently with the walk's resolve()).
|
|
48
|
-
* - search is case-sensitive String.includes (fixed string, not regex).
|
|
49
|
-
*
|
|
50
|
-
* Files are read once and cached so repeated searches (one per manifest row)
|
|
51
|
-
* do not re-walk the tree.
|
|
52
|
-
*/
|
|
53
|
-
export function buildCorpusSearch({
|
|
54
|
-
repoRoot,
|
|
55
|
-
includeDirs,
|
|
56
|
-
excludePaths = [],
|
|
57
|
-
extensions = DEFAULT_EXTENSIONS,
|
|
58
|
-
}) {
|
|
59
|
-
const excludeAbs = new Set(excludePaths.map((p) => resolve(repoRoot, p)));
|
|
60
|
-
const contents = [];
|
|
61
|
-
for (const dir of includeDirs) {
|
|
62
|
-
const abs = resolve(repoRoot, dir);
|
|
63
|
-
let isDir = false;
|
|
64
|
-
try {
|
|
65
|
-
isDir = statSync(abs).isDirectory();
|
|
66
|
-
} catch {
|
|
67
|
-
isDir = false;
|
|
68
|
-
}
|
|
69
|
-
if (!isDir) continue;
|
|
70
|
-
for (const file of walk(abs, excludeAbs, extensions)) {
|
|
71
|
-
try {
|
|
72
|
-
contents.push(readFileSync(file, 'utf-8'));
|
|
73
|
-
} catch {
|
|
74
|
-
/* unreadable file — skip */
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return (literal) => contents.some((text) => text.includes(literal));
|
|
79
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* fix-manifest — evidence mapping for claimed-merged fixes (Phase 2, A-sot).
|
|
3
|
-
*
|
|
4
|
-
* ADR 0036: this module is the *evidence* SoT (fix → test + ADR-line), NOT the
|
|
5
|
-
* *status* SoT. "Is fix N merged?" is answered solely by the wiki spec
|
|
6
|
-
* (parseStatus). A manifest row says "if the spec claims N merged, here is the
|
|
7
|
-
* test that proves the behavior and the production-code line that proves the
|
|
8
|
-
* ADR's core decision shipped."
|
|
9
|
-
*
|
|
10
|
-
* Shape (ADR 0036 decision 2 — NO `status` field):
|
|
11
|
-
* { fixId:number, testNames:string[], adrPath:string|null, adrKeyLine:string }
|
|
12
|
-
*
|
|
13
|
-
* - testNames: MUST set-equal the `// @fix #N:` anchors in tests/runner.mjs
|
|
14
|
-
* (drift is an error — MANIFEST_TEST_DRIFT). Multiple anchors → multiple
|
|
15
|
-
* names. The NO_AUTO_TEST sentinel is the ONLY allowed lone entry; it may
|
|
16
|
-
* not be mixed with real test names.
|
|
17
|
-
* - adrPath: path (relative to the wiki root) of the ADR whose core decision
|
|
18
|
-
* this fix implements. `null` iff adrKeyLine is the NO_ADR sentinel.
|
|
19
|
-
* - adrKeyLine: a maintainer-curated literal that embodies the fix's shipped
|
|
20
|
-
* decision and exists verbatim in production code (scripts/ hooks/ commands/
|
|
21
|
-
* skills/ templates/). Verified by fixed-string grep — 0 hits is
|
|
22
|
-
* ADR_LINE_MISSING. The NO_ADR sentinel exempts a fix that has no ADR (small
|
|
23
|
-
* / doctor fixes); the test-green check still applies. NO_ADR is NOT for
|
|
24
|
-
* fixes that have an ADR but whose evidence lives outside the corpus.
|
|
25
|
-
*
|
|
26
|
-
* Coverage contract: every fix that is BOTH claimed-merged in the spec AND
|
|
27
|
-
* anchored in the runner must have exactly one row here (MANIFEST_MISSING_ROW
|
|
28
|
-
* is an error). Fixes anchored but not claimed (e.g. #18) are ORPHAN_ANCHOR
|
|
29
|
-
* warnings and need no row.
|
|
30
|
-
*
|
|
31
|
-
* Corpus note (spec §A amendment, 2026-06-07): the ADR-line grep corpus is
|
|
32
|
-
* scripts/ hooks/ commands/ skills/ AND templates/. templates/ ships via npm
|
|
33
|
-
* `files`, so prompt-driven fixes whose decision is installed as template text
|
|
34
|
-
* (e.g. #20 proactive close offer) are honestly verifiable there.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
export const NO_ADR = 'NO_ADR';
|
|
38
|
-
export const NO_AUTO_TEST = 'NO_AUTO_TEST';
|
|
39
|
-
|
|
40
|
-
export const FIX_MANIFEST = [
|
|
41
|
-
{
|
|
42
|
-
fixId: 15,
|
|
43
|
-
testNames: ['all type-conditional fields present → green'],
|
|
44
|
-
adrPath: 'decisions/0030-hypoignore-enforce-all-injection-hooks.md',
|
|
45
|
-
adrKeyLine: 'isIgnored(path, HYPO_DIR, patterns)',
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
fixId: 17,
|
|
49
|
-
testNames: [
|
|
50
|
-
'5 mandatory memory files fresh → suppressOutput:true',
|
|
51
|
-
'project hot.md not updated today → block, reason names the file',
|
|
52
|
-
'open-questions.md absent/stale → still passes (conditional, not gated)',
|
|
53
|
-
],
|
|
54
|
-
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
55
|
-
adrKeyLine: 'sessionCloseFileStatus',
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
// Behavioral / prompt-driven: no automated test, evidence is the installed
|
|
59
|
-
// template prompt (templates/hypo-guide.md). Has an ADR (0022), so NOT
|
|
60
|
-
// NO_ADR — the adrKeyLine greps the shipped template text.
|
|
61
|
-
fixId: 20,
|
|
62
|
-
testNames: [NO_AUTO_TEST],
|
|
63
|
-
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
64
|
-
adrKeyLine: '이 작업이 마무리되었나요? 세션을 정리(crystallize)할까요?',
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
fixId: 25,
|
|
68
|
-
testNames: [
|
|
69
|
-
'replay-compact-guard-detects-slash-clear: /clear with incomplete wiki → WIKI_AUTOCLOSE',
|
|
70
|
-
],
|
|
71
|
-
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
72
|
-
adrKeyLine: '[WIKI_AUTOCLOSE]',
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
// Removal fix (capacity bypass deleted). The shipped evidence is the
|
|
76
|
-
// deliberate removal-marker comment + the negative-control test.
|
|
77
|
-
fixId: 26,
|
|
78
|
-
testNames: [
|
|
79
|
-
'replay-personal-check-bypass-order: wiki-context-critical.json does NOT bypass (negative control)',
|
|
80
|
-
],
|
|
81
|
-
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
82
|
-
adrKeyLine: 'Capacity bypass (≥90%) REMOVED',
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
fixId: 27,
|
|
86
|
-
testNames: [
|
|
87
|
-
'replay-auto-minimal-crystallize-on-incomplete-close: mutating + no marker + close-intent → block',
|
|
88
|
-
'replay-auto-minimal-crystallize-on-incomplete-close: valid marker → continue (even with close-intent)',
|
|
89
|
-
],
|
|
90
|
-
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
91
|
-
adrKeyLine: 'The hook NEVER writes the marker',
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
// No dedicated ADR (schema-vocab tag validation); test-green only.
|
|
95
|
-
fixId: 36,
|
|
96
|
-
testNames: ['PascalCase tag → error', 'unknown tag (not in vocab) → error'],
|
|
97
|
-
adrPath: null,
|
|
98
|
-
adrKeyLine: NO_ADR,
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
fixId: 38,
|
|
102
|
-
testNames: [
|
|
103
|
-
'clean-wiki payload → ok:true, new entries appended (apply dedup is exact-entry, not date-based)',
|
|
104
|
-
'idempotent: re-running same payload produces no new bytes (file mtimes unchanged)',
|
|
105
|
-
],
|
|
106
|
-
adrPath: 'decisions/0029-crystallize-session-close-depth-expansion.md',
|
|
107
|
-
adrKeyLine: 'exact-entry append dedup',
|
|
108
|
-
},
|
|
109
|
-
];
|
|
@@ -1,439 +0,0 @@
|
|
|
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 #N: all type-conditional fields present → green
|
|
31
|
-
* // @fix #N: 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. a line where the first
|
|
83
|
-
// mention is "(resolved)" and a later one is "(advisory)" — only the first
|
|
84
|
-
// should be picked up).
|
|
85
|
-
const lines = specText.split('\n');
|
|
86
|
-
const PROXIMITY = 120; // chars after fix # to scan for status
|
|
87
|
-
for (const line of lines) {
|
|
88
|
-
// Quick reject: line must mention a positive status token at all.
|
|
89
|
-
const hasPositive =
|
|
90
|
-
/\bTRUE_MERGED\b/.test(line) ||
|
|
91
|
-
/\bresolved\b/.test(line) ||
|
|
92
|
-
/(?<![A-Z_])merged(?![A-Z_])/.test(line);
|
|
93
|
-
if (!hasPositive) continue;
|
|
94
|
-
// Two accepted fix # forms:
|
|
95
|
-
// (a) inline prose: "fix #N" (word-boundary)
|
|
96
|
-
// (b) table cell start: "| #N |" (§9.1.0 status correction table)
|
|
97
|
-
const matches = [];
|
|
98
|
-
let m2;
|
|
99
|
-
const inlineRe = /\bfix\s*#(\d+)\b/gi;
|
|
100
|
-
while ((m2 = inlineRe.exec(line)) !== null) {
|
|
101
|
-
matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
|
|
102
|
-
}
|
|
103
|
-
const tableRe = /\|\s*#(\d+)\s*\|/g;
|
|
104
|
-
while ((m2 = tableRe.exec(line)) !== null) {
|
|
105
|
-
matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
|
|
106
|
-
}
|
|
107
|
-
for (const { fixNum, end } of matches) {
|
|
108
|
-
const window = line.slice(end, end + PROXIMITY);
|
|
109
|
-
// Determine the strongest status in the proximity window. Priority:
|
|
110
|
-
// TRUE_MERGED > resolved > merged.
|
|
111
|
-
let status = null;
|
|
112
|
-
if (/\bTRUE_MERGED\b/.test(window)) status = 'TRUE_MERGED';
|
|
113
|
-
else if (/\bresolved\b/.test(window)) status = 'resolved';
|
|
114
|
-
else if (/(?<![A-Z_])merged(?![A-Z_])/.test(window)) status = 'merged';
|
|
115
|
-
if (status) out.set(fixNum, status); // last positive mention wins
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Parse runner.mjs stdout to map test names → "pass" | "fail".
|
|
123
|
-
*
|
|
124
|
-
* The harness prints ` ✓ <name>` on pass and ` ✗ <name>` on fail.
|
|
125
|
-
*/
|
|
126
|
-
export function parseRunnerOutput(stdout) {
|
|
127
|
-
const out = new Map();
|
|
128
|
-
const lines = stdout.split('\n');
|
|
129
|
-
for (const line of lines) {
|
|
130
|
-
const passM = line.match(/^\s*✓\s+(.+?)\s*$/);
|
|
131
|
-
if (passM) {
|
|
132
|
-
// Sticky pass — only set if no prior result. A later fail must NOT be
|
|
133
|
-
// overridden, and a prior fail must not be flipped back to pass.
|
|
134
|
-
if (!out.has(passM[1])) out.set(passM[1], 'pass');
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const failM = line.match(/^\s*✗\s+(.+?)\s*$/);
|
|
138
|
-
if (failM) {
|
|
139
|
-
// Fail is sticky: once a name has any failure, the verdict stays fail
|
|
140
|
-
// even if a duplicate test() with the same name passed elsewhere.
|
|
141
|
-
out.set(failM[1], 'fail');
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return out;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Cross-check anchors × status × test results.
|
|
149
|
-
*
|
|
150
|
-
* Finding classes:
|
|
151
|
-
* NO_ANCHOR — fix claimed positive in spec, no anchor in runner.
|
|
152
|
-
* MISSING_TEST — anchor names a test, runner output does not contain it.
|
|
153
|
-
* FAILING_TEST — anchor names a test, runner output marks it failed.
|
|
154
|
-
* ORPHAN_ANCHOR — anchor exists, no positive status claim in spec (warn).
|
|
155
|
-
* STUB_SPEC — spec is unusable: a `type: reference` redirect stub, or
|
|
156
|
-
* it parses zero positive status claims while anchors exist
|
|
157
|
-
* (the vacuous-gate the tool exists to prevent). Error.
|
|
158
|
-
*
|
|
159
|
-
* Returns { ok, findings: [...] }. ok=false if any ERROR-level finding.
|
|
160
|
-
* ORPHAN_ANCHOR is WARN-only.
|
|
161
|
-
*
|
|
162
|
-
* STUB_SPEC is a precondition failure, so it short-circuits: when the spec is
|
|
163
|
-
* unusable there is nothing meaningful to cross-check, and the per-anchor
|
|
164
|
-
* ORPHAN noise would only bury the one decisive error.
|
|
165
|
-
*/
|
|
166
|
-
export function verifyMatrix({ anchors, status, testResults, specIsStub = false }) {
|
|
167
|
-
if (specIsStub) {
|
|
168
|
-
return {
|
|
169
|
-
ok: false,
|
|
170
|
-
findings: [
|
|
171
|
-
{
|
|
172
|
-
level: 'error',
|
|
173
|
-
class: 'STUB_SPEC',
|
|
174
|
-
detail:
|
|
175
|
-
'spec is a `type: reference` redirect stub (0 fix-status claims by design) — pass --spec pointing at the real spec',
|
|
176
|
-
},
|
|
177
|
-
],
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
// Vacuous-gate invariant: anchors exist in the runner but the spec yields no
|
|
181
|
-
// positive status claim to verify them against. Greening here would defeat
|
|
182
|
-
// the tool's purpose. (No anchors + no claims is an empty/custom matrix, not
|
|
183
|
-
// a vacuous gate, so it is left to the normal path.)
|
|
184
|
-
if (status.size === 0 && anchors.size > 0) {
|
|
185
|
-
return {
|
|
186
|
-
ok: false,
|
|
187
|
-
findings: [
|
|
188
|
-
{
|
|
189
|
-
level: 'error',
|
|
190
|
-
class: 'STUB_SPEC',
|
|
191
|
-
detail: `${anchors.size} anchor(s) in runner but 0 positive status claims parsed from spec — gate would be vacuous`,
|
|
192
|
-
},
|
|
193
|
-
],
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const findings = [];
|
|
198
|
-
|
|
199
|
-
for (const [fixNum, statusValue] of status.entries()) {
|
|
200
|
-
const anchored = anchors.get(fixNum);
|
|
201
|
-
if (!anchored || anchored.length === 0) {
|
|
202
|
-
findings.push({
|
|
203
|
-
level: 'error',
|
|
204
|
-
class: 'NO_ANCHOR',
|
|
205
|
-
fixNum,
|
|
206
|
-
status: statusValue,
|
|
207
|
-
detail: `claimed ${statusValue} in spec but no // fix #${fixNum}: anchor in runner.mjs`,
|
|
208
|
-
});
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
// Sentinel: explicit "no automated test by design" (behavioral /
|
|
212
|
-
// prompt-driven fixes). The fix is still claimed-merged but verifying it
|
|
213
|
-
// is out of scope for an integration runner.
|
|
214
|
-
if (anchored.length === 1 && anchored[0] === 'NO_AUTO_TEST') {
|
|
215
|
-
findings.push({
|
|
216
|
-
level: 'info',
|
|
217
|
-
class: 'NO_AUTO_TEST',
|
|
218
|
-
fixNum,
|
|
219
|
-
status: statusValue,
|
|
220
|
-
detail: `fix #${fixNum} declares NO_AUTO_TEST (behavioral / prompt-driven)`,
|
|
221
|
-
});
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
for (const testName of anchored) {
|
|
225
|
-
const result = testResults.get(testName);
|
|
226
|
-
if (result === undefined) {
|
|
227
|
-
findings.push({
|
|
228
|
-
level: 'error',
|
|
229
|
-
class: 'MISSING_TEST',
|
|
230
|
-
fixNum,
|
|
231
|
-
status: statusValue,
|
|
232
|
-
testName,
|
|
233
|
-
detail: `anchor names "${testName}" but no such test ran`,
|
|
234
|
-
});
|
|
235
|
-
} else if (result === 'fail') {
|
|
236
|
-
findings.push({
|
|
237
|
-
level: 'error',
|
|
238
|
-
class: 'FAILING_TEST',
|
|
239
|
-
fixNum,
|
|
240
|
-
status: statusValue,
|
|
241
|
-
testName,
|
|
242
|
-
detail: `test "${testName}" failed`,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
for (const [fixNum, names] of anchors.entries()) {
|
|
249
|
-
if (!status.has(fixNum)) {
|
|
250
|
-
findings.push({
|
|
251
|
-
level: 'warn',
|
|
252
|
-
class: 'ORPHAN_ANCHOR',
|
|
253
|
-
fixNum,
|
|
254
|
-
tests: names,
|
|
255
|
-
detail: `anchor exists for fix #${fixNum} but no positive status claim in spec`,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const ok = !findings.some((f) => f.level === 'error');
|
|
261
|
-
return { ok, findings };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ── Phase 2 (A-sot) — manifest validation, coverage/drift, ADR-line grep ─────
|
|
265
|
-
// ADR 0036: manifest is the evidence SoT (fix → test + ADR-line). status SoT
|
|
266
|
-
// stays in the spec. These are pure functions; the CLI injects fs-backed
|
|
267
|
-
// searchFn / adrExistsFn so the corpus walk stays out of the pure layer.
|
|
268
|
-
|
|
269
|
-
import { FIX_MANIFEST, NO_ADR, NO_AUTO_TEST } from './fix-manifest.mjs';
|
|
270
|
-
|
|
271
|
-
/** Order-insensitive set equality over string arrays (deduped). */
|
|
272
|
-
function sameStringSet(a, b) {
|
|
273
|
-
const sa = new Set(a);
|
|
274
|
-
const sb = new Set(b);
|
|
275
|
-
if (sa.size !== sb.size) return false;
|
|
276
|
-
for (const x of sa) if (!sb.has(x)) return false;
|
|
277
|
-
return true;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Structural validation of the manifest shape (ADR 0036).
|
|
282
|
-
*
|
|
283
|
-
* Findings (all error):
|
|
284
|
-
* MANIFEST_DUP_FIXID — two rows share a fixId.
|
|
285
|
-
* MANIFEST_EMPTY_TESTS — testNames is empty.
|
|
286
|
-
* MANIFEST_SENTINEL_MIX — NO_AUTO_TEST mixed with real test names.
|
|
287
|
-
* MANIFEST_EMPTY_KEYLINE — adrKeyLine missing/blank.
|
|
288
|
-
* MANIFEST_NO_ADR_SHAPE — NO_ADR row with non-null adrPath, or a non-NO_ADR
|
|
289
|
-
* row with null adrPath.
|
|
290
|
-
*/
|
|
291
|
-
export function validateManifest(manifest = FIX_MANIFEST) {
|
|
292
|
-
const findings = [];
|
|
293
|
-
const seen = new Set();
|
|
294
|
-
for (const row of manifest) {
|
|
295
|
-
const fixNum = row.fixId;
|
|
296
|
-
if (seen.has(fixNum)) {
|
|
297
|
-
findings.push({
|
|
298
|
-
level: 'error',
|
|
299
|
-
class: 'MANIFEST_DUP_FIXID',
|
|
300
|
-
fixNum,
|
|
301
|
-
detail: `duplicate manifest row for fix #${fixNum}`,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
seen.add(fixNum);
|
|
305
|
-
|
|
306
|
-
const names = Array.isArray(row.testNames) ? row.testNames : [];
|
|
307
|
-
if (names.length === 0) {
|
|
308
|
-
findings.push({
|
|
309
|
-
level: 'error',
|
|
310
|
-
class: 'MANIFEST_EMPTY_TESTS',
|
|
311
|
-
fixNum,
|
|
312
|
-
detail: `fix #${fixNum} manifest row has empty testNames`,
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
if (names.includes(NO_AUTO_TEST) && names.length > 1) {
|
|
316
|
-
findings.push({
|
|
317
|
-
level: 'error',
|
|
318
|
-
class: 'MANIFEST_SENTINEL_MIX',
|
|
319
|
-
fixNum,
|
|
320
|
-
detail: `fix #${fixNum} mixes NO_AUTO_TEST sentinel with real test names`,
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const keyLine = typeof row.adrKeyLine === 'string' ? row.adrKeyLine.trim() : '';
|
|
325
|
-
if (!keyLine) {
|
|
326
|
-
findings.push({
|
|
327
|
-
level: 'error',
|
|
328
|
-
class: 'MANIFEST_EMPTY_KEYLINE',
|
|
329
|
-
fixNum,
|
|
330
|
-
detail: `fix #${fixNum} manifest row has empty adrKeyLine`,
|
|
331
|
-
});
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
const isNoAdr = row.adrKeyLine === NO_ADR;
|
|
335
|
-
if (isNoAdr && row.adrPath != null) {
|
|
336
|
-
findings.push({
|
|
337
|
-
level: 'error',
|
|
338
|
-
class: 'MANIFEST_NO_ADR_SHAPE',
|
|
339
|
-
fixNum,
|
|
340
|
-
detail: `fix #${fixNum} is NO_ADR but adrPath is not null`,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
if (!isNoAdr && row.adrPath == null) {
|
|
344
|
-
findings.push({
|
|
345
|
-
level: 'error',
|
|
346
|
-
class: 'MANIFEST_NO_ADR_SHAPE',
|
|
347
|
-
fixNum,
|
|
348
|
-
detail: `fix #${fixNum} has a real adrKeyLine but null adrPath`,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return findings;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Coverage + drift between manifest, runner anchors, and spec status claims.
|
|
357
|
-
*
|
|
358
|
-
* MANIFEST_MISSING_ROW — a fix claimed-merged AND anchored has no manifest
|
|
359
|
-
* row (its ADR-line check would be silently skipped).
|
|
360
|
-
* Error: a missing row bypasses the whole gate.
|
|
361
|
-
* MANIFEST_TEST_DRIFT — a manifest row's testNames do not set-equal the
|
|
362
|
-
* runner anchors for that fix (stale evidence).
|
|
363
|
-
*
|
|
364
|
-
* Both error-level. The claimed∩anchored requirement mirrors the manifest
|
|
365
|
-
* scope (ADR 0036): rows exist to prove claims; anchors-without-claims are
|
|
366
|
-
* ORPHAN_ANCHOR (handled in verifyMatrix), not manifest gaps.
|
|
367
|
-
*/
|
|
368
|
-
export function checkManifestCoverage({ manifest = FIX_MANIFEST, anchors, status }) {
|
|
369
|
-
const findings = [];
|
|
370
|
-
const byFix = new Map(manifest.map((r) => [r.fixId, r]));
|
|
371
|
-
|
|
372
|
-
for (const fixNum of status.keys()) {
|
|
373
|
-
if (!anchors.has(fixNum)) continue; // claimed but unanchored → NO_ANCHOR (verifyMatrix)
|
|
374
|
-
if (!byFix.has(fixNum)) {
|
|
375
|
-
findings.push({
|
|
376
|
-
level: 'error',
|
|
377
|
-
class: 'MANIFEST_MISSING_ROW',
|
|
378
|
-
fixNum,
|
|
379
|
-
detail: `fix #${fixNum} is claimed-merged and anchored but has no manifest row`,
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
for (const row of manifest) {
|
|
385
|
-
const fixNum = row.fixId;
|
|
386
|
-
const anchored = anchors.get(fixNum) || [];
|
|
387
|
-
const names = Array.isArray(row.testNames) ? row.testNames : [];
|
|
388
|
-
if (!sameStringSet(names, anchored)) {
|
|
389
|
-
findings.push({
|
|
390
|
-
level: 'error',
|
|
391
|
-
class: 'MANIFEST_TEST_DRIFT',
|
|
392
|
-
fixNum,
|
|
393
|
-
detail:
|
|
394
|
-
`fix #${fixNum} manifest testNames ${JSON.stringify(names)} ` +
|
|
395
|
-
`≠ runner anchors ${JSON.stringify(anchored)}`,
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
return findings;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* ADR-line grep: each non-NO_ADR manifest row must point at an existing ADR
|
|
404
|
-
* file and its adrKeyLine must exist verbatim in the production-code corpus.
|
|
405
|
-
*
|
|
406
|
-
* ADR_PATH_MISSING — adrPath does not resolve to a file.
|
|
407
|
-
* ADR_LINE_MISSING — adrKeyLine not found in the corpus (fixed-string).
|
|
408
|
-
*
|
|
409
|
-
* searchFn(literal) → boolean: true iff the literal appears in the corpus
|
|
410
|
-
* (the corpus MUST exclude scripts/lib/fix-manifest.mjs, else every line
|
|
411
|
-
* self-matches and the gate is vacuous — see the CLI corpus builder).
|
|
412
|
-
* adrExistsFn(adrPath) → boolean. NO_ADR rows are skipped (test-green only).
|
|
413
|
-
*/
|
|
414
|
-
export function checkAdrLines({ manifest = FIX_MANIFEST, searchFn, adrExistsFn }) {
|
|
415
|
-
const findings = [];
|
|
416
|
-
for (const row of manifest) {
|
|
417
|
-
if (row.adrKeyLine === NO_ADR) continue;
|
|
418
|
-
const fixNum = row.fixId;
|
|
419
|
-
if (row.adrPath != null && !adrExistsFn(row.adrPath)) {
|
|
420
|
-
findings.push({
|
|
421
|
-
level: 'error',
|
|
422
|
-
class: 'ADR_PATH_MISSING',
|
|
423
|
-
fixNum,
|
|
424
|
-
detail: `fix #${fixNum} adrPath does not resolve: ${row.adrPath}`,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
if (!searchFn(row.adrKeyLine)) {
|
|
428
|
-
findings.push({
|
|
429
|
-
level: 'error',
|
|
430
|
-
class: 'ADR_LINE_MISSING',
|
|
431
|
-
fixNum,
|
|
432
|
-
detail: `fix #${fixNum} adrKeyLine not found in production corpus: "${row.adrKeyLine}"`,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return findings;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
export { POSITIVE_STATUSES, NEGATIVE_STATUS_TOKENS, FIX_MANIFEST, NO_ADR, NO_AUTO_TEST };
|