@holmes-lab/holmes-kit 0.25.0 → 0.26.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/CHANGELOG.md +91 -0
- package/README.md +8 -3
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.js +7 -26
- package/dist/holmes/cli/index.js +8 -1
- package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
- package/dist/holmes/cpg/cycle-observation.js +25 -2
- package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
- package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
- package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
- package/dist/holmes/cpg/forbidden-edges.js +135 -2
- package/dist/holmes/cpg/import-resolver.d.ts +12 -0
- package/dist/holmes/cpg/import-resolver.js +215 -0
- package/dist/holmes/cpg/language-capability.d.ts +14 -0
- package/dist/holmes/cpg/language-capability.js +37 -13
- package/dist/holmes/cpg/proposed-content.d.ts +7 -1
- package/dist/holmes/cpg/proposed-content.js +7 -0
- package/dist/holmes/governance/approval-queue.d.ts +33 -0
- package/dist/holmes/governance/approval-queue.js +85 -0
- package/dist/holmes/hooks/pre-tool-use.js +8 -1
- package/dist/holmes/hooks/session-start.js +39 -0
- package/dist/holmes/hooks/stop.d.ts +38 -2
- package/dist/holmes/hooks/stop.js +153 -49
- package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
- package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
- package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
- package/dist/holmes/project/report-briefing.d.ts +48 -0
- package/dist/holmes/project/report-briefing.js +70 -0
- package/dist/holmes/project/resolved-reports.d.ts +20 -0
- package/dist/holmes/project/resolved-reports.js +7 -0
- package/dist/holmes/rtm/rtm-builder.js +6 -141
- package/package.json +1 -1
- package/playbooks/publish/PLAYBOOK.md +18 -0
|
@@ -56,6 +56,8 @@ exports.decideStopGuard = decideStopGuard;
|
|
|
56
56
|
exports.__setWiredSpecsForTest = __setWiredSpecsForTest;
|
|
57
57
|
exports.guardCountOrZero = guardCountOrZero;
|
|
58
58
|
exports.readGuardCount = readGuardCount;
|
|
59
|
+
exports.collectForbiddenEdgeJudgement = collectForbiddenEdgeJudgement;
|
|
60
|
+
exports.scanCodeCycles = scanCodeCycles;
|
|
59
61
|
exports.writeGuardCount = writeGuardCount;
|
|
60
62
|
const fs = __importStar(require("node:fs"));
|
|
61
63
|
// @implements A-SPEC-549.3 — evaluateStop's messages are English here; the Hangul-absence guard
|
|
@@ -88,6 +90,8 @@ const test_files_1 = require("../cpg/test-files");
|
|
|
88
90
|
const ci_runs_1 = require("../project/ci-runs");
|
|
89
91
|
const dist_freshness_1 = require("../project/dist-freshness");
|
|
90
92
|
const analysis_currency_1 = require("../project/analysis-currency");
|
|
93
|
+
const forbidden_edges_1 = require("../cpg/forbidden-edges");
|
|
94
|
+
const forbidden_edge_report_1 = require("../cpg/forbidden-edge-report");
|
|
91
95
|
// @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
|
|
92
96
|
// (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
|
|
93
97
|
// judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
|
|
@@ -699,6 +703,15 @@ function evaluateStop(specs, evidence) {
|
|
|
699
703
|
if (detail)
|
|
700
704
|
tracked = [...(tracked ?? []), { article: 'ANALYSIS', detail }];
|
|
701
705
|
}
|
|
706
|
+
// @implements A-SPEC-693 — the declared architecture's verdict rides the same non-blocking channel.
|
|
707
|
+
// Blocking is the pre-edit gate's job and an expired baseline is never a block (owner decision,
|
|
708
|
+
// 2026-09-19); what was missing is that nobody could SEE a violation, an expiry or an unjudged
|
|
709
|
+
// language. Never enters `problems`.
|
|
710
|
+
if (evidence?.forbiddenEdges) {
|
|
711
|
+
const t = (0, forbidden_edge_report_1.forbiddenEdgeStatusLines)(evidence.forbiddenEdges).map((detail) => ({ article: 'ARCH', detail }));
|
|
712
|
+
if (t.length)
|
|
713
|
+
tracked = [...(tracked ?? []), ...t];
|
|
714
|
+
}
|
|
702
715
|
// @implements A-SPEC-683 — the vector-coverage line rides the same non-blocking channel. It is a
|
|
703
716
|
// STRING here, not a verdict object: the hook read what the refresh child wrote and has nothing
|
|
704
717
|
// to judge. An absent verdict already reads as "not run" inside coverageStopLine.
|
|
@@ -1018,6 +1031,120 @@ function readGuardCount(sessionId) {
|
|
|
1018
1031
|
* Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
|
|
1019
1032
|
* and a test that can only reach it through a spawned hook cannot pin what the lock does.
|
|
1020
1033
|
*/
|
|
1034
|
+
/**
|
|
1035
|
+
* @implements A-SPEC-688
|
|
1036
|
+
* Type erasure is a TypeScript fact, not a universal one.
|
|
1037
|
+
*
|
|
1038
|
+
* `classifyEdgeByTarget` reads TS syntax to decide whether an import survives into the emit. Python,
|
|
1039
|
+
* Go, Java, C#, Rust and C++ have no erasure — every import there is an eager runtime dependency —
|
|
1040
|
+
* so running a TS-syntax detector over their source can only mislabel. REQ-574 sorted cycles into
|
|
1041
|
+
* (a) type-erasable, (b) lazy require and (c) eager value; category (a) does not exist in these
|
|
1042
|
+
* languages, so the classification is not consulted and the edge is eager by construction.
|
|
1043
|
+
*/
|
|
1044
|
+
// @implements A-SPEC-693 — the gate is "can the classifier READ this syntax", not "does this language
|
|
1045
|
+
// erase types". The classifier reads `require()` and `from`, which is the whole JS family; gating on
|
|
1046
|
+
// erasure alone switched it off for `.js/.cjs/.mjs/.jsx` and REQ-574's category (b), lazy require,
|
|
1047
|
+
// stopped being recorded there. The other languages stay eager by construction, as above.
|
|
1048
|
+
const CLASSIFIER_READS = /\.[cm]?[jt]sx?$/;
|
|
1049
|
+
function kindOfEdge(text, from, to) {
|
|
1050
|
+
return CLASSIFIER_READS.test(from) ? (0, cycle_detect_1.classifyEdgeByTarget)(text, from, to) : 'eager-value';
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* @implements A-SPEC-693
|
|
1054
|
+
* The scan-wide forbidden-edge judgement, over the SAME rules the pre-edit gate reads: approved
|
|
1055
|
+
* C-SPECs only, rules and baselines out of one parse of one section.
|
|
1056
|
+
*
|
|
1057
|
+
* Returns `undefined` when no rule is declared — the project never adopted this, so it pays nothing
|
|
1058
|
+
* and hears nothing. The scan is handed in rather than made here: the Stop hook already scans once a
|
|
1059
|
+
* turn for the cycle ratchet, and scanning is that hook's dominant cost.
|
|
1060
|
+
*/
|
|
1061
|
+
function collectForbiddenEdgeJudgement(specs, scanned, now) {
|
|
1062
|
+
const rules = [];
|
|
1063
|
+
const baselines = [];
|
|
1064
|
+
for (const sp of specs) {
|
|
1065
|
+
if (sp.type !== 'C-SPEC' || sp.status !== 'approved')
|
|
1066
|
+
continue;
|
|
1067
|
+
const parsed = (0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '');
|
|
1068
|
+
rules.push(...parsed.rules);
|
|
1069
|
+
baselines.push(...parsed.baselines);
|
|
1070
|
+
}
|
|
1071
|
+
if (rules.length === 0)
|
|
1072
|
+
return undefined;
|
|
1073
|
+
return (0, forbidden_edges_1.judgeForbiddenEdges)(scanned, rules, { baselines, now });
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* @implements A-SPEC-688
|
|
1077
|
+
* The code graph's cycles, judged where the code exists — extracted from the hook body so the
|
|
1078
|
+
* judgement is observable without driving a whole Stop turn.
|
|
1079
|
+
*
|
|
1080
|
+
* Returns `undefined` when the scan could not run at all. That is NOT "no cycles": the caller must
|
|
1081
|
+
* keep the article silent rather than report a clean tree, because "we could not look" and "there
|
|
1082
|
+
* is nothing there" are different facts (REQ-574).
|
|
1083
|
+
*
|
|
1084
|
+
* @implements A-SPEC-693 — `shared` is a scan the caller already made. When given it is BELIEVED
|
|
1085
|
+
* and no second scan runs, so one turn pays for one scan however many judgements read it.
|
|
1086
|
+
*/
|
|
1087
|
+
function scanCodeCycles(root, shared) {
|
|
1088
|
+
try {
|
|
1089
|
+
const { CpgScanner } = require('../cpg/cpg-scanner');
|
|
1090
|
+
const { createImportResolver } = require('../cpg/import-resolver');
|
|
1091
|
+
const scanned = shared ?? new CpgScanner().scan(root, root);
|
|
1092
|
+
// The resolver the graph builder uses. Re-deriving it here is what made seven language
|
|
1093
|
+
// families read as `unavailable` while the tree could already resolve them.
|
|
1094
|
+
const resolver = createImportResolver(scanned);
|
|
1095
|
+
const edges = [];
|
|
1096
|
+
const judged = new Set();
|
|
1097
|
+
const sawImports = new Set();
|
|
1098
|
+
for (const f of scanned) {
|
|
1099
|
+
const ext = f.sourcePath.slice(f.sourcePath.lastIndexOf('.'));
|
|
1100
|
+
let text = '';
|
|
1101
|
+
try {
|
|
1102
|
+
text = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
let sawImport = false;
|
|
1108
|
+
for (const e of f.edges ?? []) {
|
|
1109
|
+
if (e.rel !== 'imports' || !e.to)
|
|
1110
|
+
continue;
|
|
1111
|
+
sawImport = true;
|
|
1112
|
+
// Go: a package specifier names every file of the package, so one specifier is many edges.
|
|
1113
|
+
// The ratchet must see the same graph the builder does, or the two disagree about cycles.
|
|
1114
|
+
const targets = [...resolver.fanOut(f.sourcePath, e.to)];
|
|
1115
|
+
const single = targets.length === 0 ? resolver.resolve(f.sourcePath, e.to) : null;
|
|
1116
|
+
if (single !== null)
|
|
1117
|
+
targets.push(single);
|
|
1118
|
+
for (const to of targets) {
|
|
1119
|
+
if (to === f.sourcePath)
|
|
1120
|
+
continue;
|
|
1121
|
+
edges.push({ from: f.sourcePath, to, kind: kindOfEdge(text, f.sourcePath, to) });
|
|
1122
|
+
judged.add(ext);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// A file whose imports are all bare specifiers is not evidence that its LANGUAGE is
|
|
1126
|
+
// unresolvable — subtract the judged set at the end instead of marking per file, or one
|
|
1127
|
+
// such file makes the whole language read as unjudged (measured: `.ts` and `.js` landed in
|
|
1128
|
+
// both lists).
|
|
1129
|
+
if (sawImport)
|
|
1130
|
+
sawImports.add(ext);
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
current: (0, cycle_detect_1.detectCycles)(edges).cycles,
|
|
1134
|
+
allowed: [],
|
|
1135
|
+
mode: 'track',
|
|
1136
|
+
// Unjudged = saw imports, resolved none, anywhere. Naming these is the difference between
|
|
1137
|
+
// "no cycles" and "no cycles in the languages we could follow".
|
|
1138
|
+
scope: {
|
|
1139
|
+
judged: [...judged].sort(),
|
|
1140
|
+
unavailable: [...sawImports].filter((e) => !judged.has(e)).sort(),
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
return undefined;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1021
1148
|
function writeGuardCount(sessionId, n) {
|
|
1022
1149
|
// @implements A-SPEC-191 §19 — no project, no state. Minting `<cwd>/.ax` to hold a counter is how
|
|
1023
1150
|
// the gate lost the project in the first place; returning false puts the caller on the stateless
|
|
@@ -1145,56 +1272,33 @@ if (require.main === module) {
|
|
|
1145
1272
|
// import edge starts from. Fail-open in every direction: any error leaves the article silent,
|
|
1146
1273
|
// because "we could not look" must never be reported as "there is nothing there".
|
|
1147
1274
|
let cycles;
|
|
1275
|
+
// @implements A-SPEC-693 — ONE scan a turn, read by both judgements below. A scan that cannot
|
|
1276
|
+
// run leaves both silent; neither judgement scans again on its own.
|
|
1277
|
+
let sharedScan;
|
|
1148
1278
|
try {
|
|
1149
|
-
const root = stopProjectRoot();
|
|
1150
1279
|
const { CpgScanner } = require('../cpg/cpg-scanner');
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
const to = ['.ts', '.tsx', '.js', '/index.ts'].map((x) => base + x).find((c) => known.has(c))
|
|
1174
|
-
?? (known.has(base) ? base : null);
|
|
1175
|
-
if (to === null || to === f.sourcePath)
|
|
1176
|
-
continue;
|
|
1177
|
-
edges.push({ from: f.sourcePath, to, kind: (0, cycle_detect_1.classifyEdgeByTarget)(text, f.sourcePath, to) });
|
|
1178
|
-
judged.add(ext);
|
|
1179
|
-
}
|
|
1180
|
-
// A file whose imports are all bare specifiers is not evidence that its LANGUAGE is
|
|
1181
|
-
// unresolvable — subtract the judged set at the end instead of marking per file, or one
|
|
1182
|
-
// such file makes the whole language read as unjudged (measured: `.ts` and `.js` landed in
|
|
1183
|
-
// both lists).
|
|
1184
|
-
if (sawImport)
|
|
1185
|
-
sawImports.add(ext);
|
|
1186
|
-
}
|
|
1187
|
-
cycles = {
|
|
1188
|
-
current: (0, cycle_detect_1.detectCycles)(edges).cycles,
|
|
1189
|
-
allowed: [],
|
|
1190
|
-
mode: 'track',
|
|
1191
|
-
// Unjudged = saw imports, resolved none, anywhere. Naming these is the difference between
|
|
1192
|
-
// "no cycles" and "no cycles in the languages we could follow".
|
|
1193
|
-
scope: {
|
|
1194
|
-
judged: [...judged].sort(),
|
|
1195
|
-
unavailable: [...sawImports].filter((e) => !judged.has(e)).sort(),
|
|
1196
|
-
},
|
|
1197
|
-
};
|
|
1280
|
+
sharedScan = new CpgScanner().scan(stopProjectRoot(), stopProjectRoot());
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
sharedScan = undefined;
|
|
1284
|
+
}
|
|
1285
|
+
// Its OWN try, like the observation below: a fault in the architecture judgement must not
|
|
1286
|
+
// discard the cycle evidence, nor the other way round.
|
|
1287
|
+
let forbiddenEdges;
|
|
1288
|
+
try {
|
|
1289
|
+
if (sharedScan !== undefined)
|
|
1290
|
+
forbiddenEdges = collectForbiddenEdgeJudgement(specs, sharedScan, new Date());
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
forbiddenEdges = undefined;
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
const root = stopProjectRoot();
|
|
1297
|
+
if (sharedScan === undefined)
|
|
1298
|
+
throw new Error('cycle scan unavailable');
|
|
1299
|
+
cycles = scanCodeCycles(root, sharedScan);
|
|
1300
|
+
if (cycles === undefined)
|
|
1301
|
+
throw new Error('cycle scan unavailable');
|
|
1198
1302
|
// @implements A-SPEC-578.1 — record the observation the promotion criterion waits on.
|
|
1199
1303
|
// EVERY run, including a clean one: a false-positive rate is violations over chances, and a
|
|
1200
1304
|
// ledger that only speaks when something is wrong keeps the numerator and drops the
|
|
@@ -1322,7 +1426,7 @@ if (require.main === module) {
|
|
|
1322
1426
|
catch {
|
|
1323
1427
|
semantic = undefined;
|
|
1324
1428
|
}
|
|
1325
|
-
let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}), ...(semantic ? { semantic } : {}) });
|
|
1429
|
+
let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}), ...(semantic ? { semantic } : {}), ...(forbiddenEdges ? { forbiddenEdges } : {}) });
|
|
1326
1430
|
// @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
|
|
1327
1431
|
// the operator observes RED-first gaps before an owner promotes the posture to strict.
|
|
1328
1432
|
// @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
|
|
@@ -36,13 +36,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.renumberedMappingInputs = renumberedMappingInputs;
|
|
37
37
|
exports.integrationSealTransitions = integrationSealTransitions;
|
|
38
38
|
exports.createEntityIntegrationHandlers = createEntityIntegrationHandlers;
|
|
39
|
-
// @implements A-SPEC-629
|
|
40
|
-
// @implements A-SPEC-645
|
|
41
|
-
// @implements A-SPEC-642
|
|
42
|
-
// @implements A-SPEC-639
|
|
43
|
-
// @implements A-SPEC-632
|
|
44
|
-
// @implements A-SPEC-635
|
|
45
|
-
const fs = __importStar(require("node:fs"));
|
|
46
39
|
const path = __importStar(require("node:path"));
|
|
47
40
|
const entity_store_1 = require("../../spec/entity-store");
|
|
48
41
|
const root_1 = require("../../project/root");
|
|
@@ -129,7 +122,11 @@ function createEntityIntegrationHandlers(context) {
|
|
|
129
122
|
if (!context.storeRoot)
|
|
130
123
|
throw new entity_store_1.EntityStoreError('unsupported-store', 'Git integration requires a configured filesystem spec store.');
|
|
131
124
|
const bound = (0, entity_store_1.entityStoreBinding)(context.storeRoot);
|
|
132
|
-
|
|
125
|
+
// @implements A-SPEC-692 — `bound.root` is already canonical (A-SPEC-651.1 says so in as
|
|
126
|
+
// many words); the JS `fs.realpathSync` that stood on this side is not, and it leaves a
|
|
127
|
+
// Windows 8.3 alias spelled as it arrived. The same workspace then read as foreign.
|
|
128
|
+
// `sameCanonicalDirectory` exists for exactly this comparison — one canonicaliser, both sides.
|
|
129
|
+
if (a.root !== undefined && !(0, root_1.sameCanonicalDirectory)((0, root_1.resolveProjectRoot)(a.root, { includeGit: false }).root, bound.root))
|
|
133
130
|
throw new entity_store_1.EntityStoreError('foreign-workspace', 'The requested workspace differs from the configured store.');
|
|
134
131
|
const recoveryTarget = (plan) => 'entity-store:integrate-recover:' + (0, entity_transaction_1.entityContentVersion)(JSON.stringify(plan));
|
|
135
132
|
const requireStore = (plan) => {
|
|
@@ -34,10 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.createEntityRenumberHandlers = createEntityRenumberHandlers;
|
|
37
|
-
// @implements A-SPEC-628
|
|
38
|
-
// @implements A-SPEC-642
|
|
39
|
-
// @implements A-SPEC-639
|
|
40
|
-
const fs = __importStar(require("node:fs"));
|
|
41
37
|
const entity_store_1 = require("../../spec/entity-store");
|
|
42
38
|
const entity_renumber_1 = require("../../spec/entity-renumber");
|
|
43
39
|
const root_1 = require("../../project/root");
|
|
@@ -56,7 +52,10 @@ function createEntityRenumberHandlers(context) {
|
|
|
56
52
|
if (!context.storeRoot)
|
|
57
53
|
throw new entity_store_1.EntityStoreError('unsupported-store', 'Entity renumbering requires a configured filesystem spec store.');
|
|
58
54
|
const bound = (0, entity_store_1.entityStoreBinding)(context.storeRoot);
|
|
59
|
-
|
|
55
|
+
// @implements A-SPEC-692 — one canonicaliser on both sides; see the twin in
|
|
56
|
+
// `entity-integration.ts`. `bound.root` is canonical, so this side must be too, or an 8.3
|
|
57
|
+
// spelling of the SAME workspace is refused as foreign.
|
|
58
|
+
if (a.root !== undefined && !(0, root_1.sameCanonicalDirectory)((0, root_1.resolveProjectRoot)(a.root, { includeGit: false }).root, bound.root))
|
|
60
59
|
throw new entity_store_1.EntityStoreError('foreign-workspace', 'The requested workspace differs from the configured store.');
|
|
61
60
|
if (a.operation === 'recovery-plan')
|
|
62
61
|
return { ok: true, plan: (0, entity_renumber_transaction_1.planEntityRenumberRecovery)(context.storeRoot) };
|
|
@@ -42,6 +42,7 @@ const validator_1 = require("../../spec/validator");
|
|
|
42
42
|
const spec_types_1 = require("../../spec/spec-types");
|
|
43
43
|
const legacy_format_1 = require("../../spec/legacy-format");
|
|
44
44
|
const write_target_1 = require("../../guardrail/write-target");
|
|
45
|
+
const root_1 = require("../../project/root");
|
|
45
46
|
const spec_id_guard_1 = require("../spec-id-guard");
|
|
46
47
|
function createSpecAuthoringHandlers(context) {
|
|
47
48
|
const { store, projectRootOf, resolver } = context;
|
|
@@ -105,8 +106,15 @@ function createSpecAuthoringHandlers(context) {
|
|
|
105
106
|
// deployments refuse their own root. It is "you named a project that already exists, and my
|
|
106
107
|
// store is not inside it", which is true whatever the specs directory is called.
|
|
107
108
|
if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate === null && boundSpecsRoot !== null) {
|
|
109
|
+
// @implements A-SPEC-692 — BOTH sides of this comparison go through ONE canonicaliser.
|
|
110
|
+
// `projectRootOf` below already expands Windows 8.3 aliases (it ends in `canonicalPath`,
|
|
111
|
+
// i.e. `realpathSync.native`); the JS `fs.realpathSync` that used to stand here does not.
|
|
112
|
+
// So the same directory arrived under two spellings and the containment test said "another
|
|
113
|
+
// project": measured 2026-09-19, `C:\Users\SUNGNA~1\…\probe83` was refused while
|
|
114
|
+
// `C:\Users\SungNam Park\…\probe83` — the same directory — created. `change-source.ts`
|
|
115
|
+
// named this rule on 2026-08-23 after the identical failure; this is the same rule, here.
|
|
108
116
|
const real = (p2) => { try {
|
|
109
|
-
return
|
|
117
|
+
return (0, root_1.canonicalPath)(p2);
|
|
110
118
|
}
|
|
111
119
|
catch {
|
|
112
120
|
return path.resolve(p2);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other end of the loop `holmes-kit report` opened.
|
|
3
|
+
*
|
|
4
|
+
* `report` writes every fingerprint this machine reported into `.ax/reports/reported.jsonl`, and
|
|
5
|
+
* until now nothing read it — the file's own header said it existed "for a briefing that does not
|
|
6
|
+
* exist yet". So the set accumulated on consumers' disks while the person who took the trouble to
|
|
7
|
+
* report a defect learned nothing when it was fixed.
|
|
8
|
+
*
|
|
9
|
+
* The match is a **local set intersection**: a file this machine wrote against a list that shipped
|
|
10
|
+
* in the package. Nothing is sent, nothing is requested, and there is no device id and no MAC
|
|
11
|
+
* address — a fingerprint identifies a DEFECT, not a person, which is why it can do this job at all.
|
|
12
|
+
*
|
|
13
|
+
* PURE. Every input is passed in; the caller owns the filesystem and the clock.
|
|
14
|
+
*/
|
|
15
|
+
import type { ResolvedReport } from './resolved-reports';
|
|
16
|
+
export interface BriefingInput {
|
|
17
|
+
reported: ReadonlyArray<{
|
|
18
|
+
fingerprint: string;
|
|
19
|
+
}>;
|
|
20
|
+
resolved: ReadonlyArray<ResolvedReport>;
|
|
21
|
+
/** The version running now. */
|
|
22
|
+
installed: string;
|
|
23
|
+
/** Versions this installation's code contains — the installed one and everything before it. */
|
|
24
|
+
shipped: ReadonlyArray<string>;
|
|
25
|
+
announced: ReadonlySet<string>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Lines to say, or none.
|
|
29
|
+
*
|
|
30
|
+
* A fingerprint is briefed when this machine reported it, the list resolves it, the resolving
|
|
31
|
+
* version is one this installation actually CONTAINS, and it has not been said before.
|
|
32
|
+
*
|
|
33
|
+
* "Contains" is membership in the shipped list, not semver arithmetic: the question is whether the
|
|
34
|
+
* consumer is running the fix, and a list of released versions answers that without this module
|
|
35
|
+
* having to know how versions compare.
|
|
36
|
+
*/
|
|
37
|
+
export declare function briefingLines(input: BriefingInput): string[];
|
|
38
|
+
/** Tolerant: one unparseable line must not take the rest of the set down. */
|
|
39
|
+
export declare function reportedFrom(raw: string | null): Array<{
|
|
40
|
+
fingerprint: string;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* An unreadable marker yields an EMPTY set on purpose: the briefing then speaks. Hearing it twice
|
|
44
|
+
* is a smaller harm than a consumer who never hears it, and that is the rule the tier invitation
|
|
45
|
+
* follows for the same reason.
|
|
46
|
+
*/
|
|
47
|
+
export declare function announcedFrom(raw: string | null): Set<string>;
|
|
48
|
+
export declare function announcedTo(set: ReadonlySet<string>): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.briefingLines = briefingLines;
|
|
4
|
+
exports.reportedFrom = reportedFrom;
|
|
5
|
+
exports.announcedFrom = announcedFrom;
|
|
6
|
+
exports.announcedTo = announcedTo;
|
|
7
|
+
/**
|
|
8
|
+
* Lines to say, or none.
|
|
9
|
+
*
|
|
10
|
+
* A fingerprint is briefed when this machine reported it, the list resolves it, the resolving
|
|
11
|
+
* version is one this installation actually CONTAINS, and it has not been said before.
|
|
12
|
+
*
|
|
13
|
+
* "Contains" is membership in the shipped list, not semver arithmetic: the question is whether the
|
|
14
|
+
* consumer is running the fix, and a list of released versions answers that without this module
|
|
15
|
+
* having to know how versions compare.
|
|
16
|
+
*/
|
|
17
|
+
function briefingLines(input) {
|
|
18
|
+
const mine = new Set(input.reported.map((r) => r.fingerprint).filter((f) => typeof f === 'string' && f !== ''));
|
|
19
|
+
const has = new Set([...input.shipped, input.installed]);
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const r of input.resolved) {
|
|
22
|
+
if (!mine.has(r.fingerprint))
|
|
23
|
+
continue; // not ours — this is not a release-notes feed
|
|
24
|
+
if (!has.has(r.version))
|
|
25
|
+
continue; // not running the fix yet; the update notice owns that
|
|
26
|
+
if (input.announced.has(r.fingerprint))
|
|
27
|
+
continue; // once is once
|
|
28
|
+
out.push(`a defect you reported (${r.fingerprint}) was fixed in ${r.version} — ${r.note}`);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Tolerant: one unparseable line must not take the rest of the set down. */
|
|
33
|
+
function reportedFrom(raw) {
|
|
34
|
+
if (raw === null)
|
|
35
|
+
return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of raw.split('\n')) {
|
|
38
|
+
const t = line.trim();
|
|
39
|
+
if (t === '')
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
const o = JSON.parse(t);
|
|
43
|
+
if (typeof o.fingerprint === 'string' && o.fingerprint !== '')
|
|
44
|
+
out.push({ fingerprint: o.fingerprint });
|
|
45
|
+
}
|
|
46
|
+
catch { /* a malformed line is skipped, never fatal */ }
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* An unreadable marker yields an EMPTY set on purpose: the briefing then speaks. Hearing it twice
|
|
52
|
+
* is a smaller harm than a consumer who never hears it, and that is the rule the tier invitation
|
|
53
|
+
* follows for the same reason.
|
|
54
|
+
*/
|
|
55
|
+
function announcedFrom(raw) {
|
|
56
|
+
if (raw === null)
|
|
57
|
+
return new Set();
|
|
58
|
+
try {
|
|
59
|
+
const o = JSON.parse(raw);
|
|
60
|
+
if (!Array.isArray(o.announced))
|
|
61
|
+
return new Set();
|
|
62
|
+
return new Set(o.announced.filter((x) => typeof x === 'string' && x !== ''));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return new Set();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function announcedTo(set) {
|
|
69
|
+
return JSON.stringify({ announced: [...set].sort() }, null, 2) + '\n';
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field reports this project has resolved, and the version that resolved them.
|
|
3
|
+
*
|
|
4
|
+
* A DATA MODULE rather than a JSON file: TypeScript compiles it into `dist/` automatically, so it
|
|
5
|
+
* ships with no change to `package.json`'s `files` and no build step anyone can forget.
|
|
6
|
+
*
|
|
7
|
+
* The fingerprint is the one that appeared in the report — `advisoryId`-shaped, the first 16 hex of
|
|
8
|
+
* a sha256 over the canonical report. It identifies a DEFECT, never a person or a machine.
|
|
9
|
+
*
|
|
10
|
+
* This list is kept alive by the publish ritual, not by a command: before cutting a release the
|
|
11
|
+
* playbook asks for the fingerprints that release resolved. A command nobody runs would leave this
|
|
12
|
+
* empty and the briefing permanently silent, and this repository already records six instruments
|
|
13
|
+
* that were built and never consumed.
|
|
14
|
+
*/
|
|
15
|
+
export interface ResolvedReport {
|
|
16
|
+
fingerprint: string;
|
|
17
|
+
version: string;
|
|
18
|
+
note: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const RESOLVED_REPORTS: ReadonlyArray<ResolvedReport>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RESOLVED_REPORTS = void 0;
|
|
4
|
+
exports.RESOLVED_REPORTS = [
|
|
5
|
+
// Empty until a field report is resolved. The briefing is silent while it is, which is correct:
|
|
6
|
+
// no consumer has reported anything this project has since fixed.
|
|
7
|
+
];
|
|
@@ -46,6 +46,7 @@ exports.addDecisionEdges = addDecisionEdges;
|
|
|
46
46
|
const path = __importStar(require("node:path"));
|
|
47
47
|
const fs = __importStar(require("node:fs"));
|
|
48
48
|
const language_capability_1 = require("../cpg/language-capability");
|
|
49
|
+
const import_resolver_1 = require("../cpg/import-resolver");
|
|
49
50
|
/**
|
|
50
51
|
* @implements A-SPEC-281
|
|
51
52
|
* Build one fact's provenance from the shared observation context plus what this call site knows.
|
|
@@ -429,146 +430,10 @@ function fsReadGoMod(p) {
|
|
|
429
430
|
}
|
|
430
431
|
}
|
|
431
432
|
function addImportEdges(scanned, graph, opts) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
// @implements A-SPEC-525.1 — the five languages' resolution rules, selected by the IMPORTING
|
|
437
|
-
// file's extension because resolution IS per-language semantics. The founding rule is unchanged
|
|
438
|
-
// everywhere: resolve only to files the scan actually contains; the ambiguous resolve to none.
|
|
439
|
-
const knownList = [...known];
|
|
440
|
-
const uniqueSuffix = (suffix) => {
|
|
441
|
-
const hits = knownList.filter((k) => k === suffix || k.endsWith(`/${suffix}`));
|
|
442
|
-
return hits.length === 1 ? hits[0] : null; // two files may not become a guess
|
|
443
|
-
};
|
|
444
|
-
// go.mod discovery: injected via opts when the caller knows it; otherwise walked up from the
|
|
445
|
-
// first Go file's ABSOLUTE path (bounded), so production builds resolve without extra wiring.
|
|
446
|
-
let goModCache;
|
|
447
|
-
const goModuleOf = () => {
|
|
448
|
-
if (opts?.goModule)
|
|
449
|
-
return opts.goModule;
|
|
450
|
-
if (goModCache !== undefined)
|
|
451
|
-
return goModCache ?? undefined;
|
|
452
|
-
goModCache = null;
|
|
453
|
-
const anyGo = scanned.find((f) => /\.go$/.test(f.sourcePath) && f.path);
|
|
454
|
-
if (anyGo) {
|
|
455
|
-
let dir = path.dirname(anyGo.path);
|
|
456
|
-
for (let hops = 0; hops < 12; hops++) {
|
|
457
|
-
try {
|
|
458
|
-
const txt = fsReadGoMod(path.join(dir, 'go.mod'));
|
|
459
|
-
if (txt !== null) {
|
|
460
|
-
goModCache = txt;
|
|
461
|
-
break;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
catch { /* keep walking */ }
|
|
465
|
-
const up = path.dirname(dir);
|
|
466
|
-
if (up === dir)
|
|
467
|
-
break;
|
|
468
|
-
dir = up;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
return goModCache ?? undefined;
|
|
472
|
-
};
|
|
473
|
-
const goPackage = (spec) => {
|
|
474
|
-
const mod = goModuleOf();
|
|
475
|
-
if (!mod || !(spec === mod || spec.startsWith(`${mod}/`)))
|
|
476
|
-
return []; // external, honestly
|
|
477
|
-
const dir = spec === mod ? '' : spec.slice(mod.length + 1);
|
|
478
|
-
const prefix = dir === '' ? '' : `${dir}/`;
|
|
479
|
-
return knownList.filter((k) => k.startsWith(prefix) && k.endsWith('.go')
|
|
480
|
-
&& !k.endsWith('_test.go') && !k.slice(prefix.length).includes('/'));
|
|
481
|
-
};
|
|
482
|
-
const rustResolve = (fromFile, spec) => {
|
|
483
|
-
const segs = spec.split('::');
|
|
484
|
-
const head = segs.shift();
|
|
485
|
-
let baseDir;
|
|
486
|
-
if (head === 'crate') {
|
|
487
|
-
// the crate root is the src/ directory nearest above the importing file
|
|
488
|
-
const m = fromFile.match(/^(.*?src)\//);
|
|
489
|
-
baseDir = m ? m[1] : 'src';
|
|
490
|
-
}
|
|
491
|
-
else if (head === 'super') {
|
|
492
|
-
baseDir = path.posix.dirname(path.posix.dirname(fromFile));
|
|
493
|
-
while (segs[0] === 'super') {
|
|
494
|
-
// @implements A-SPEC-527.1 — super:: above the crate root is an rustc error; resolving
|
|
495
|
-
// it pinned '.' and produced edges to repo-root files (adversarial sweep). Refuse.
|
|
496
|
-
if (baseDir === '.' || baseDir === '/')
|
|
497
|
-
return null;
|
|
498
|
-
segs.shift();
|
|
499
|
-
baseDir = path.posix.dirname(baseDir);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
else if (head === 'self') {
|
|
503
|
-
baseDir = path.posix.dirname(fromFile);
|
|
504
|
-
}
|
|
505
|
-
else {
|
|
506
|
-
return null; // external crate
|
|
507
|
-
}
|
|
508
|
-
const tryPath = (parts) => {
|
|
509
|
-
if (parts.length === 0)
|
|
510
|
-
return null;
|
|
511
|
-
const base = path.posix.normalize(path.posix.join(baseDir, ...parts));
|
|
512
|
-
if (known.has(`${base}.rs`))
|
|
513
|
-
return `${base}.rs`;
|
|
514
|
-
if (known.has(`${base}/mod.rs`))
|
|
515
|
-
return `${base}/mod.rs`;
|
|
516
|
-
return null;
|
|
517
|
-
};
|
|
518
|
-
// the last segment may be an ITEM, not a module — drop it once and retry
|
|
519
|
-
return tryPath(segs) ?? tryPath(segs.slice(0, -1));
|
|
520
|
-
};
|
|
521
|
-
const resolve = (fromFile, spec) => {
|
|
522
|
-
// @implements A-SPEC-525.1 — language branches BEFORE the JS/Python-shaped fallthrough.
|
|
523
|
-
if (/\.go$/.test(fromFile))
|
|
524
|
-
return null; // Go fans out separately (a package is its files)
|
|
525
|
-
if (/\.rs$/.test(fromFile))
|
|
526
|
-
return rustResolve(fromFile, spec);
|
|
527
|
-
if (/\.java$/.test(fromFile))
|
|
528
|
-
return uniqueSuffix(`${spec.split('.').join('/')}.java`);
|
|
529
|
-
if (/\.cs$/.test(fromFile))
|
|
530
|
-
return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
|
|
531
|
-
if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
|
|
532
|
-
// @implements A-SPEC-527.1 — an ABSOLUTE include is not a repository coordinate: joining
|
|
533
|
-
// it into the repo frame let `#include "/etc/passwd"` match a coincidentally-shaped
|
|
534
|
-
// scanned file (adversarial sweep). Absolute means absolute; it resolves to nothing here.
|
|
535
|
-
if (spec.startsWith('/'))
|
|
536
|
-
return null;
|
|
537
|
-
// extension preserved: the dotted split below would butcher `util/env.h`
|
|
538
|
-
const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
|
|
539
|
-
if (known.has(relative))
|
|
540
|
-
return relative;
|
|
541
|
-
return known.has(spec) ? spec : null; // repo-root-relative include
|
|
542
|
-
}
|
|
543
|
-
// @implements A-SPEC-406
|
|
544
|
-
// A specifier without a leading dot used to be refused outright, on the ground that it names an
|
|
545
|
-
// external package. That holds for `node:fs` and `js-yaml`; it does NOT hold for Python, which
|
|
546
|
-
// writes intra-repository modules absolutely — `from src.core.memory_audit import X`. Measured
|
|
547
|
-
// on the jarvis corpus: 1060 specifiers, 0 relative, 542 naming a file the scan already had, and
|
|
548
|
-
// 0 import edges in the graph. The whole import layer was missing for that language because the
|
|
549
|
-
// resolver's shape was JS's.
|
|
550
|
-
//
|
|
551
|
-
// The rule the original was written under is unchanged and is what makes this safe: resolve
|
|
552
|
-
// ONLY to a file the scan actually contains. `os.path` becomes `os/path.py`, which is in no
|
|
553
|
-
// scan, so it still emits nothing. Nothing is invented; the dotted form is simply also read.
|
|
554
|
-
if (!spec.startsWith('.')) {
|
|
555
|
-
if (spec.includes(':'))
|
|
556
|
-
return null; // `node:fs` and friends are never a repository path
|
|
557
|
-
return firstKnown(path.posix.normalize(spec.split('.').filter(Boolean).join('/')));
|
|
558
|
-
}
|
|
559
|
-
// @implements A-SPEC-289
|
|
560
|
-
// Python writes a relative import as `.b` / `..pkg.mod`, where the leading dots are LEVELS and
|
|
561
|
-
// the remaining dots are path separators — not the `./b` form JS uses. Treating `.b` as a path
|
|
562
|
-
// yields the literal name `.b`, which matches nothing. Detected by the absence of a slash: a
|
|
563
|
-
// specifier that already contains one is a JS-style path and is left alone.
|
|
564
|
-
let relative = spec;
|
|
565
|
-
if (!spec.includes('/')) {
|
|
566
|
-
const dots = spec.match(/^\.+/)[0].length;
|
|
567
|
-
const rest = spec.slice(dots).split('.').filter(Boolean).join('/');
|
|
568
|
-
relative = `${'../'.repeat(Math.max(0, dots - 1)) || './'}${rest}`;
|
|
569
|
-
}
|
|
570
|
-
return firstKnown(path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), relative)));
|
|
571
|
-
};
|
|
433
|
+
// @implements A-SPEC-688 — the resolution rules moved to `cpg/import-resolver` so the cycle
|
|
434
|
+
// ratchet consumes the SAME code instead of re-deriving a weaker copy. The rules themselves are
|
|
435
|
+
// unchanged; this call is where they used to be spelled out.
|
|
436
|
+
const { resolve, fanOut } = (0, import_resolver_1.createImportResolver)(scanned, { goModule: opts?.goModule });
|
|
572
437
|
graph.transaction(() => {
|
|
573
438
|
for (const f of scanned) {
|
|
574
439
|
for (const e of f.edges ?? []) {
|
|
@@ -578,7 +443,7 @@ function addImportEdges(scanned, graph, opts) {
|
|
|
578
443
|
// language's semantics, not an invention), so one specifier fans out to each scanned
|
|
579
444
|
// file of the package directory.
|
|
580
445
|
if (/\.go$/.test(f.sourcePath)) {
|
|
581
|
-
for (const target of
|
|
446
|
+
for (const target of fanOut(f.sourcePath, e.to)) {
|
|
582
447
|
graph.addNode(`FILE:${f.sourcePath}`, 'FILE', f.sourcePath, fact(opts, f.sourcePath, 'ast-scan', 1));
|
|
583
448
|
graph.addNode(`FILE:${target}`, 'FILE', target, fact(opts, target, 'ast-scan', 1));
|
|
584
449
|
graph.addEdge(`FILE:${f.sourcePath}`, `FILE:${target}`, 'imports', f.sourcePath, fact(opts, `${f.sourcePath} -> ${e.to}`, 'import-resolution', 1));
|