@holmes-lab/holmes-kit 0.1.9 → 0.1.11
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 +118 -0
- package/README.md +48 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/doctor.d.ts +39 -0
- package/dist/holmes/cli/doctor.js +79 -0
- package/dist/holmes/cli/test-platform.d.ts +25 -0
- package/dist/holmes/cli/test-platform.js +38 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
- package/dist/holmes/cpg/cpg-scanner.js +53 -1
- package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
- package/dist/holmes/cpg/forbidden-edges.js +140 -0
- package/dist/holmes/cpg/hash-cache.js +13 -5
- package/dist/holmes/cpg/language-parser-walk.js +70 -4
- package/dist/holmes/cpg/proposed-content.d.ts +51 -0
- package/dist/holmes/cpg/proposed-content.js +72 -0
- package/dist/holmes/cpg/required-calls.d.ts +62 -0
- package/dist/holmes/cpg/required-calls.js +93 -0
- package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
- package/dist/holmes/guardrail/cspec-change.js +70 -0
- package/dist/holmes/guardrail/risk-classifier.js +122 -0
- package/dist/holmes/guardrail/write-target.d.ts +42 -0
- package/dist/holmes/guardrail/write-target.js +69 -18
- package/dist/holmes/hooks/pre-tool-use.js +90 -5
- package/dist/holmes/hooks/stop.d.ts +17 -0
- package/dist/holmes/hooks/stop.js +39 -2
- package/dist/holmes/mcp/handlers.d.ts +41 -0
- package/dist/holmes/mcp/handlers.js +173 -3
- package/dist/holmes/mcp/tool-schemas.js +12 -0
- package/dist/holmes/project/dependencies.d.ts +15 -0
- package/dist/holmes/project/dependencies.js +58 -0
- package/dist/holmes/project/json-state.d.ts +24 -0
- package/dist/holmes/project/json-state.js +30 -0
- package/dist/holmes/reverse/scan.js +8 -1
- package/dist/holmes/review/scope.d.ts +29 -0
- package/dist/holmes/review/scope.js +44 -0
- package/dist/holmes/rtm/test-scope.d.ts +44 -0
- package/dist/holmes/rtm/test-scope.js +92 -2
- package/dist/holmes/server/dashboard.d.ts +22 -0
- package/dist/holmes/server/dashboard.js +114 -24
- package/dist/holmes/spec/approval-blockers.d.ts +21 -5
- package/dist/holmes/spec/approval-blockers.js +49 -6
- package/dist/holmes/spec/legacy-format.d.ts +14 -0
- package/dist/holmes/spec/legacy-format.js +15 -1
- package/dist/holmes/spec/nonfunctional.d.ts +70 -0
- package/dist/holmes/spec/nonfunctional.js +119 -0
- package/dist/holmes/spec/spec-parser.d.ts +25 -0
- package/dist/holmes/spec/spec-parser.js +46 -2
- package/dist/holmes/spec/spec-types.d.ts +4 -1
- package/dist/holmes/spec/spec-types.js +13 -1
- package/dist/holmes/testing/effects.d.ts +54 -0
- package/dist/holmes/testing/effects.js +107 -0
- package/package.json +3 -2
- package/playbooks/promote-slice/PLAYBOOK.md +20 -0
|
@@ -41,6 +41,7 @@ const os = __importStar(require("node:os"));
|
|
|
41
41
|
const path = __importStar(require("node:path"));
|
|
42
42
|
const crypto = __importStar(require("node:crypto"));
|
|
43
43
|
const node_child_process_1 = require("node:child_process");
|
|
44
|
+
const spec_parser_1 = require("../spec/spec-parser");
|
|
44
45
|
const validator_1 = require("../spec/validator");
|
|
45
46
|
const rtm_check_1 = require("../rtm/rtm-check");
|
|
46
47
|
const test_scope_1 = require("../rtm/test-scope");
|
|
@@ -106,6 +107,7 @@ const ledger_store_1 = require("../governance/ledger-store");
|
|
|
106
107
|
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
107
108
|
const ledger_lock_1 = require("../governance/ledger-lock");
|
|
108
109
|
const decision_ledger_1 = require("../guardrail/decision-ledger");
|
|
110
|
+
const cspec_change_1 = require("../guardrail/cspec-change");
|
|
109
111
|
/**
|
|
110
112
|
* @implements A-SPEC-189 §7 (round 10)
|
|
111
113
|
* A DELIBERATE refusal, distinguished by construction rather than by class.
|
|
@@ -428,6 +430,45 @@ function basisFor(root, withDisk = false) {
|
|
|
428
430
|
}
|
|
429
431
|
function makeRawHandlers(store) {
|
|
430
432
|
const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
|
|
433
|
+
/**
|
|
434
|
+
* Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
|
|
435
|
+
*
|
|
436
|
+
* @implements A-SPEC-222
|
|
437
|
+
* Extracted verbatim from spec_approve so retirement cannot drift from approval on the one rule
|
|
438
|
+
* A-SPEC-188 paid for twice: a bad root once left a standing seal with no ledger entry while the
|
|
439
|
+
* caller was told it failed, and a foreign root filed the only audit record in another project's
|
|
440
|
+
* ledger. Two copies of that rule would be two chances to lose it again.
|
|
441
|
+
*/
|
|
442
|
+
const resolveLedgerRoot = (root) => {
|
|
443
|
+
if (store instanceof spec_store_1.LocalMarkdownRepository) {
|
|
444
|
+
const derived = projectRootOf(store.specsRoot);
|
|
445
|
+
const derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
|
|
446
|
+
if (derivationFoundMarker) {
|
|
447
|
+
if (typeof root === 'string' && root !== '') {
|
|
448
|
+
const askedProject = (0, write_target_1.resolveTarget)(projectRootOf(root), '.');
|
|
449
|
+
const boundProject = (0, write_target_1.resolveTarget)(derived, '.');
|
|
450
|
+
if (askedProject !== boundProject) {
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
reason: `이 서버는 ${boundProject} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${root}는 ${askedProject} 프로젝트를 가리킵니다.`
|
|
454
|
+
+ ' 다른 프로젝트의 원장에 기록하지 않기 위해 쓰기 전에 거부합니다.',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { ok: true, root: derived };
|
|
459
|
+
}
|
|
460
|
+
if (typeof root === 'string' && root !== '')
|
|
461
|
+
return { ok: true, root: projectRootOf(root) };
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
reason: '스토어 위치에서 프로젝트를 파생할 수 없습니다(.ax 상위 디렉터리 없음) — 원장을 어디에 둘지 알 수 없어 쓰기 전에 거부합니다. root를 지정하십시오.',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (typeof root !== 'string' || root === '') {
|
|
468
|
+
return { ok: false, reason: 'root가 없고 스토어에서 원장 위치를 파생할 수도 없습니다 — 쓰기 전에 거부합니다. root를 지정하십시오.' };
|
|
469
|
+
}
|
|
470
|
+
return { ok: true, root: projectRootOf(root) };
|
|
471
|
+
};
|
|
431
472
|
return {
|
|
432
473
|
async spec_create(a) {
|
|
433
474
|
// @implements A-SPEC-169
|
|
@@ -674,6 +715,114 @@ function makeRawHandlers(store) {
|
|
|
674
715
|
const remaining = (0, validator_1.validateSpec)(upgraded, resolver(after)).findings.filter((f) => f.level === 'error');
|
|
675
716
|
return { ok: true, upgraded: true, type: plan.type, legacyStatus: plan.legacyStatus, remaining };
|
|
676
717
|
},
|
|
718
|
+
/**
|
|
719
|
+
* Move a document to `outdated` — the only path there.
|
|
720
|
+
*
|
|
721
|
+
* @implements A-SPEC-222
|
|
722
|
+
* `outdated` was in SPEC_STATUSES with no code writing it and no document carrying it: a
|
|
723
|
+
* declared state nothing could reach. That mattered the moment 23 deprecated REQs needed
|
|
724
|
+
* cleaning up, because the only transition tool sends everything to `draft`, and calling a
|
|
725
|
+
* retired document "draft" is a worse lie than the non-canonical word it replaced.
|
|
726
|
+
*
|
|
727
|
+
* Retirement withdraws authority rather than granting it, so it does NOT need the approval key
|
|
728
|
+
* by default. Two cases invert that, and the threshold follows the RISK rather than the name of
|
|
729
|
+
* the act:
|
|
730
|
+
*
|
|
731
|
+
* - A sealed document. The code gate demands an approved T-SPEC naming the target A-SPEC;
|
|
732
|
+
* retiring that T-SPEC removes the demand. Unguarded, retirement is an approval bypass.
|
|
733
|
+
* - A document an APPROVED spec depends on. That chain is holding something up right now.
|
|
734
|
+
*/
|
|
735
|
+
async spec_retire(a) {
|
|
736
|
+
const all = await store.list();
|
|
737
|
+
if (all.filter((s) => s.id === a.id).length > 1) {
|
|
738
|
+
return {
|
|
739
|
+
ok: false,
|
|
740
|
+
reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 폐기 전에 거부합니다.`,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const cur = await store.read(a.id);
|
|
744
|
+
if (!cur)
|
|
745
|
+
return { ok: false, reason: `spec ${a.id} not found` };
|
|
746
|
+
const spec = cur.spec;
|
|
747
|
+
// @implements A-SPEC-222 — a document with no `type:` cannot be filed: the store derives its
|
|
748
|
+
// folder from the kind, and there is none. Refuse rather than infer, because a wrongly
|
|
749
|
+
// inferred kind MOVES the document into another folder. Measured 2026-08-21: without this the
|
|
750
|
+
// store threw `Cannot read properties of undefined (reading 'folder')` on the first of the 23
|
|
751
|
+
// legacy documents this tool was built for — every unit fixture carried `type:`, which is how
|
|
752
|
+
// it got through. Declaring the kind is `spec_upgrade`'s job, so the refusal names it.
|
|
753
|
+
if (!spec.type) {
|
|
754
|
+
return {
|
|
755
|
+
ok: false,
|
|
756
|
+
reason: `${a.id}에는 \`type:\` 선언이 없습니다(구형식 문서) — 어느 폴더에 속하는지 알 수 없어 폐기할 수 없습니다.`
|
|
757
|
+
+ ' 종류를 추론하지 않는 이유는 잘못 추론한 종류가 문서를 다른 폴더로 옮기기 때문입니다.'
|
|
758
|
+
+ ` 먼저 spec_upgrade({ id: "${a.id}" })로 형식을 올린 뒤 다시 폐기하십시오 — 그 도구가 이전 status를 legacy_status로 보존합니다.`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
// Idempotent, and it writes NOTHING on the second call: overwriting the first retirement's
|
|
762
|
+
// reason would make the record graffiti rather than history.
|
|
763
|
+
if (spec.status === 'outdated') {
|
|
764
|
+
return { ok: true, retired: false, id: a.id, dependents: [], reason: `${a.id}은(는) 이미 outdated 입니다` };
|
|
765
|
+
}
|
|
766
|
+
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
767
|
+
let approval;
|
|
768
|
+
try {
|
|
769
|
+
approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
approval = undefined;
|
|
773
|
+
}
|
|
774
|
+
const covered = (0, risk_gate_1.approvalCovers)(approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
|
|
775
|
+
const sealed = typeof spec.frontmatter.approved_digest === 'string';
|
|
776
|
+
if (sealed && !covered) {
|
|
777
|
+
return {
|
|
778
|
+
ok: false,
|
|
779
|
+
reason: `${a.id}은(는) 봉인된 문서입니다 — 폐기는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
|
|
780
|
+
+ ' 코드 게이트를 막고 있는 approved T-SPEC 을 폐기하면 그 게이트가 열리므로, 폐기가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
|
|
781
|
+
+ ' (범위를 쓰면 kind "spec-approve")',
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
|
|
785
|
+
const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
|
|
786
|
+
if (blocking.length > 0) {
|
|
787
|
+
return {
|
|
788
|
+
ok: false,
|
|
789
|
+
reason: `${a.id}을(를) 폐기하면 approved 문서 ${blocking.join(', ')}의 사슬이 끊깁니다 — 지금 무언가를 지탱하고 있는 사슬이므로 거부합니다.`
|
|
790
|
+
+ ' 해당 문서를 먼저 폐기하거나 부모를 다른 문서로 옮기십시오.',
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
// Destination BEFORE the write, the order A-SPEC-188 fixed for spec_approve: a resolution
|
|
794
|
+
// failure must leave nothing behind.
|
|
795
|
+
const dest = resolveLedgerRoot(a.root);
|
|
796
|
+
if (!dest.ok)
|
|
797
|
+
return { ok: false, reason: dest.reason };
|
|
798
|
+
const ledgerRoot = dest.root;
|
|
799
|
+
const fm = { ...spec.frontmatter };
|
|
800
|
+
// Blank is omitted, never written: `retired_reason: ""` asserts a ground that does not exist,
|
|
801
|
+
// the same rule `legacy_status` follows. A document already carrying `superseded_by` has
|
|
802
|
+
// answered "why" already and is not asked twice.
|
|
803
|
+
const reason = typeof a.reason === 'string' ? a.reason.trim() : '';
|
|
804
|
+
if (reason && fm.superseded_by === undefined)
|
|
805
|
+
fm.retired_reason = reason;
|
|
806
|
+
try {
|
|
807
|
+
await store.write({ ...spec, status: 'outdated', frontmatter: fm }, { expectedVersion: cur.version });
|
|
808
|
+
}
|
|
809
|
+
catch (e) {
|
|
810
|
+
if (e instanceof spec_store_2.SpecVersionConflictError) {
|
|
811
|
+
return { ok: false, reason: `폐기 진행 중 ${a.id}이(가) 바뀌었습니다 — 확인 후 다시 시도하십시오. 이번 폐기는 아무것도 쓰지 않았습니다.` };
|
|
812
|
+
}
|
|
813
|
+
throw e;
|
|
814
|
+
}
|
|
815
|
+
new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger')).append({
|
|
816
|
+
ts: new Date().toISOString(),
|
|
817
|
+
actor: approval?.actor ?? 'unattributed',
|
|
818
|
+
kind: 'spec-retired',
|
|
819
|
+
summary: `retired ${a.id}${reason ? ` — ${reason}` : ''}`,
|
|
820
|
+
inputs: [a.id],
|
|
821
|
+
rationale: reason || (approval?.rationale ?? 'retired'),
|
|
822
|
+
...(approval ? { authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) } : {}),
|
|
823
|
+
});
|
|
824
|
+
return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
|
|
825
|
+
},
|
|
677
826
|
async spec_approve(a) {
|
|
678
827
|
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
679
828
|
let approval;
|
|
@@ -1164,7 +1313,7 @@ function makeRawHandlers(store) {
|
|
|
1164
1313
|
async test_run(a) {
|
|
1165
1314
|
// Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
|
|
1166
1315
|
// (what the constitution's ART-4 prefers over the syntactic count).
|
|
1167
|
-
const { root, specs, scanned, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
|
|
1316
|
+
const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
|
|
1168
1317
|
const g = new rtm_graph_1.RtmGraph();
|
|
1169
1318
|
let testScope;
|
|
1170
1319
|
try {
|
|
@@ -1175,7 +1324,11 @@ function makeRawHandlers(store) {
|
|
|
1175
1324
|
// and the unresolved remainder ride in through extras. A nonempty change set can end in a
|
|
1176
1325
|
// scoped run or a full run — never in "nothing to do".
|
|
1177
1326
|
const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
|
|
1178
|
-
|
|
1327
|
+
// @implements A-SPEC-233 — the architecture axis of `assessRisk` already calls these changes
|
|
1328
|
+
// `hard-hitl`; until now nothing on the test side consumed that verdict, so the most
|
|
1329
|
+
// dangerous change could receive the narrowest run (measured 2026-08-22: tier `unit`, one
|
|
1330
|
+
// test file, against `hard-hitl`).
|
|
1331
|
+
testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles, architectureTouched: (0, test_scope_1.architectureGoverningFiles)(changedFiles), obligationGaps: scope.obligationGaps });
|
|
1179
1332
|
// @implements A-SPEC-128
|
|
1180
1333
|
// No change set means no basis for narrowing. Widening is the same fail-safe direction
|
|
1181
1334
|
// computeTestScope already takes when no anchored test resolves — never a silently narrow run.
|
|
@@ -1343,7 +1496,7 @@ function makeRawHandlers(store) {
|
|
|
1343
1496
|
// @implements A-SPEC-130 — the same widened set as test_run, so review packaging and test
|
|
1344
1497
|
// scoping cannot disagree about what a change touches.
|
|
1345
1498
|
const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
|
|
1346
|
-
const testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles });
|
|
1499
|
+
const testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles, architectureTouched: (0, test_scope_1.architectureGoverningFiles)(changedFiles), obligationGaps: scope.obligationGaps });
|
|
1347
1500
|
// Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
|
|
1348
1501
|
// did NOT ingest (e.g. non-.ts files), computed from the same
|
|
1349
1502
|
// changedFiles/scanned already derived above — no new git/scan calls.
|
|
@@ -1575,6 +1728,23 @@ function makeRawHandlers(store) {
|
|
|
1575
1728
|
g.close(); // release native SQLite handle even if build/query throws
|
|
1576
1729
|
}
|
|
1577
1730
|
}
|
|
1731
|
+
// @implements A-SPEC-225 — the flag that raises this action's risk is COMPUTED here, not
|
|
1732
|
+
// taken from the party being judged. Measured 2026-08-22: nothing in the repository ever set
|
|
1733
|
+
// `cspecConstraintChange`, so the hard-hitl branch guarding C-SPEC constraints could only fire
|
|
1734
|
+
// if the actor changing them volunteered that they had. The precedent is one field away —
|
|
1735
|
+
// `blastRadius` above is derived from the store rather than accepted from the caller — and the
|
|
1736
|
+
// principle is the one `findings.ts` states for `basis`: a judge that stamps its own evidence
|
|
1737
|
+
// turns the field into self-report.
|
|
1738
|
+
//
|
|
1739
|
+
// OR, never assignment: a caller may raise its own risk (that is their business) but may not
|
|
1740
|
+
// lower the server's verdict.
|
|
1741
|
+
const cspecId = (0, cspec_change_1.cspecTargetId)(typeof action.target === 'string' ? action.target : '');
|
|
1742
|
+
if (cspecId !== null) {
|
|
1743
|
+
const stored = (await store.list()).find((sp) => sp.id === cspecId);
|
|
1744
|
+
const current = stored ? (0, spec_parser_1.serializeSpec)(stored) : null;
|
|
1745
|
+
const proposed = typeof action.stagedContent === 'string' ? action.stagedContent : null;
|
|
1746
|
+
action.cspecConstraintChange = (0, cspec_change_1.cspecConstraintChanged)(current, proposed) || action.cspecConstraintChange === true;
|
|
1747
|
+
}
|
|
1578
1748
|
const assessment = (0, risk_classifier_1.assessRisk)(action);
|
|
1579
1749
|
// @implements A-SPEC-133 — coverage (scope + expiry), not mere well-formedness, decides whether
|
|
1580
1750
|
// this approval authorizes THIS action. An out-of-scope or expired token no longer unblocks.
|
|
@@ -85,6 +85,18 @@ exports.TOOL_SCHEMAS = {
|
|
|
85
85
|
required: ['id'],
|
|
86
86
|
},
|
|
87
87
|
},
|
|
88
|
+
spec_retire: {
|
|
89
|
+
description: "Move ONE named spec to `status: outdated` — the only path to that state. Retirement WITHDRAWS authority, so it needs no approval key by default; two cases invert that, because there the withdrawal opens a gate. A SEALED spec requires the same out-of-band HOLMES_APPROVAL as spec_approve, fail-closed: retiring the approved T-SPEC that blocks the code gate would remove the demand, making retirement an approval bypass. And a spec that an APPROVED spec depends on is refused outright, with the dependents named — that chain is holding something up right now. Records `spec-retired` in the provenance ledger, writes the reason into `retired_reason` (omitted when blank, or when `superseded_by` already answers why), and is idempotent: retiring an already-outdated spec writes nothing rather than overwriting the first retirement's reason.",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
id: str('Id of the spec to retire.'),
|
|
94
|
+
reason: str('Why it is being retired. Omitted from the document when blank; not required when the spec already carries superseded_by.'),
|
|
95
|
+
root: str('Optional when the server is bound to a file store — the ledger location is derived from the store itself; if supplied it must resolve to the SAME project.'),
|
|
96
|
+
},
|
|
97
|
+
required: ['id'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
88
100
|
spec_approve: {
|
|
89
101
|
description: 'Approve a spec as a sealing ACT: confirm the ledger destination BEFORE sealing → validate (zero errors) → record approved_digest + parent_digests snapshots → status: approved (written only at the version this act read; a concurrent edit wins and the approval is refused for retry) → append spec-approved to the provenance ledger. Requires a valid out-of-band HOLMES_APPROVAL in the SERVER environment (fail-closed; nothing in the request can substitute). Refuses an unsealed approved parent — seal parents first.',
|
|
90
102
|
inputSchema: {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names of every directly declared runtime dependency.
|
|
3
|
+
*
|
|
4
|
+
* @implements A-SPEC-236
|
|
5
|
+
* Takes a PARSED object, never a path. Real `package.json` files leave three of the four surfaces
|
|
6
|
+
* empty, so a function that read the file itself could not be exercised against the cases that
|
|
7
|
+
* matter — the ones this exists to close.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS DOES NOT SEE. Transitive dependencies: the lockfile is not read, so a direct dependency
|
|
10
|
+
* pulling in a hundred new packages passes. That is a different claim of a different size, and the
|
|
11
|
+
* 27 prose statements this check repays ("신규 의존성 0", "No new dependency") all mean the direct
|
|
12
|
+
* ones. Versions are not compared either — they move legitimately, and a rule that reddens on every
|
|
13
|
+
* upgrade gets switched off, and a rule switched off guards nothing.
|
|
14
|
+
*/
|
|
15
|
+
export declare function declaredRuntimeDependencies(pkg: unknown): string[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-236
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.declaredRuntimeDependencies = declaredRuntimeDependencies;
|
|
5
|
+
/**
|
|
6
|
+
* Every declaration surface a runtime dependency can enter through.
|
|
7
|
+
*
|
|
8
|
+
* @implements A-SPEC-236
|
|
9
|
+
* Checking only `dependencies` would let a package arrive via `optionalDependencies` and pass — and
|
|
10
|
+
* a pass reads as "there is none". That is the shape this repository has already refused twice: in
|
|
11
|
+
* `write-target.ts`, where enumerating write tools let an unlisted one through, and in
|
|
12
|
+
* `risk-classifier.ts`, where enumerating write verbs let `sed -i` through. The surface is judged
|
|
13
|
+
* whole rather than the forbidden entries counted.
|
|
14
|
+
*
|
|
15
|
+
* `bundledDependencies` is an ARRAY of names while the other three are objects. `Object.keys` over
|
|
16
|
+
* an array yields `"0"`, `"1"`, … — a silently wrong set that still looks like an answer.
|
|
17
|
+
*/
|
|
18
|
+
const OBJECT_SURFACES = ['dependencies', 'optionalDependencies', 'peerDependencies'];
|
|
19
|
+
const ARRAY_SURFACES = ['bundledDependencies', 'bundleDependencies'];
|
|
20
|
+
/**
|
|
21
|
+
* The names of every directly declared runtime dependency.
|
|
22
|
+
*
|
|
23
|
+
* @implements A-SPEC-236
|
|
24
|
+
* Takes a PARSED object, never a path. Real `package.json` files leave three of the four surfaces
|
|
25
|
+
* empty, so a function that read the file itself could not be exercised against the cases that
|
|
26
|
+
* matter — the ones this exists to close.
|
|
27
|
+
*
|
|
28
|
+
* WHAT THIS DOES NOT SEE. Transitive dependencies: the lockfile is not read, so a direct dependency
|
|
29
|
+
* pulling in a hundred new packages passes. That is a different claim of a different size, and the
|
|
30
|
+
* 27 prose statements this check repays ("신규 의존성 0", "No new dependency") all mean the direct
|
|
31
|
+
* ones. Versions are not compared either — they move legitimately, and a rule that reddens on every
|
|
32
|
+
* upgrade gets switched off, and a rule switched off guards nothing.
|
|
33
|
+
*/
|
|
34
|
+
function declaredRuntimeDependencies(pkg) {
|
|
35
|
+
const names = new Set();
|
|
36
|
+
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
|
|
37
|
+
return [];
|
|
38
|
+
const p = pkg;
|
|
39
|
+
for (const surface of OBJECT_SURFACES) {
|
|
40
|
+
const v = p[surface];
|
|
41
|
+
// A surface holding the wrong shape is skipped rather than thrown on: this reports what a
|
|
42
|
+
// package.json declares, and a malformed field is npm's error to raise, not this function's.
|
|
43
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
44
|
+
continue;
|
|
45
|
+
for (const name of Object.keys(v))
|
|
46
|
+
if (name)
|
|
47
|
+
names.add(name);
|
|
48
|
+
}
|
|
49
|
+
for (const surface of ARRAY_SURFACES) {
|
|
50
|
+
const v = p[surface];
|
|
51
|
+
if (!Array.isArray(v))
|
|
52
|
+
continue;
|
|
53
|
+
for (const name of v)
|
|
54
|
+
if (typeof name === 'string' && name)
|
|
55
|
+
names.add(name);
|
|
56
|
+
}
|
|
57
|
+
return [...names].sort();
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is a parsed JSON value usable as a state record?
|
|
3
|
+
*
|
|
4
|
+
* @implements A-SPEC-237
|
|
5
|
+
* WHY THIS EXISTS AS A NAMED, SHARED FUNCTION. Every state reader in this repository guards itself
|
|
6
|
+
* with `try { JSON.parse(…) } catch { default }`, and that guard sees only a parse FAILURE. `null`,
|
|
7
|
+
* `42`, `"x"`, `true` and `[1,2]` are all valid JSON: the parse succeeds and the failure lands later,
|
|
8
|
+
* at the first property access, as a TypeError.
|
|
9
|
+
*
|
|
10
|
+
* Measured 2026-08-23, end to end: a project whose Stop hook answered `{"decision":"block"}` with
|
|
11
|
+
* eight article violations produced NO OUTPUT and exit 0 once `.ax/ledger/stop-guard.json` contained
|
|
12
|
+
* the four characters `null`. That file is gitignored and written by the hook itself, so one `echo`
|
|
13
|
+
* disabled governance leaving no trace. `HashCache` had the same shape with a milder consequence,
|
|
14
|
+
* while its own comment promised a damaged cache "must never block a reindex".
|
|
15
|
+
*
|
|
16
|
+
* Two call sites, ONE definition. This repository has twice paid for the alternative: `isTestFile`
|
|
17
|
+
* drifted into two copies until a Python file was excluded from the graph as a test AND gated as
|
|
18
|
+
* production at the same time, and a restated section-splitting regex returned an empty section
|
|
19
|
+
* while the check using it went green.
|
|
20
|
+
*
|
|
21
|
+
* `null` is `typeof "object"` and an array is too, which is exactly why the check cannot be a
|
|
22
|
+
* `typeof` test alone — the two forms that read as objects are the two that break.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isJsonStateObject(value: unknown): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-237
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.isJsonStateObject = isJsonStateObject;
|
|
5
|
+
/**
|
|
6
|
+
* Is a parsed JSON value usable as a state record?
|
|
7
|
+
*
|
|
8
|
+
* @implements A-SPEC-237
|
|
9
|
+
* WHY THIS EXISTS AS A NAMED, SHARED FUNCTION. Every state reader in this repository guards itself
|
|
10
|
+
* with `try { JSON.parse(…) } catch { default }`, and that guard sees only a parse FAILURE. `null`,
|
|
11
|
+
* `42`, `"x"`, `true` and `[1,2]` are all valid JSON: the parse succeeds and the failure lands later,
|
|
12
|
+
* at the first property access, as a TypeError.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-08-23, end to end: a project whose Stop hook answered `{"decision":"block"}` with
|
|
15
|
+
* eight article violations produced NO OUTPUT and exit 0 once `.ax/ledger/stop-guard.json` contained
|
|
16
|
+
* the four characters `null`. That file is gitignored and written by the hook itself, so one `echo`
|
|
17
|
+
* disabled governance leaving no trace. `HashCache` had the same shape with a milder consequence,
|
|
18
|
+
* while its own comment promised a damaged cache "must never block a reindex".
|
|
19
|
+
*
|
|
20
|
+
* Two call sites, ONE definition. This repository has twice paid for the alternative: `isTestFile`
|
|
21
|
+
* drifted into two copies until a Python file was excluded from the graph as a test AND gated as
|
|
22
|
+
* production at the same time, and a restated section-splitting regex returned an empty section
|
|
23
|
+
* while the check using it went green.
|
|
24
|
+
*
|
|
25
|
+
* `null` is `typeof "object"` and an array is too, which is exactly why the check cannot be a
|
|
26
|
+
* `typeof` test alone — the two forms that read as objects are the two that break.
|
|
27
|
+
*/
|
|
28
|
+
function isJsonStateObject(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
@@ -105,8 +105,15 @@ function candidates(root, scan) {
|
|
|
105
105
|
: all.filter((f) => !ignore.isIgnored(f.sourcePath));
|
|
106
106
|
// Tests come from the tracked list rather than from the scan, which excludes them by design. An
|
|
107
107
|
// untracked test is dropped for the same reason untracked code is: it is not part of the system.
|
|
108
|
+
//
|
|
109
|
+
// @implements A-SPEC-241
|
|
110
|
+
// Coming from the tracked list means they arrive WITHOUT the directory exclusions `scan()` applies
|
|
111
|
+
// to the source candidates above, and being tracked does not answer whether a file is vendor code:
|
|
112
|
+
// adopting holmes-kit runs `npm install`, and one `git add -A` commits the tree it created.
|
|
113
|
+
// Measured on a real Python codebase — 254 of 340 discovered tests were dependencies' own, and
|
|
114
|
+
// three were attributed to project clusters as coverage.
|
|
108
115
|
const testFiles = (tracked ? [...tracked].filter((p) => SCANNABLE_RE.test(p)) : walkTestFiles(root))
|
|
109
|
-
.filter((p) => (0, test_files_1.isTestFile)(p) && !ignore?.isIgnored(p))
|
|
116
|
+
.filter((p) => (0, test_files_1.isTestFile)(p) && !(0, cpg_scanner_1.isUnderSkippedDir)(p) && !ignore?.isIgnored(p))
|
|
110
117
|
.sort(cmp);
|
|
111
118
|
return {
|
|
112
119
|
files, testFiles, scanned: all.length, droppedUntracked: all.length - files.length,
|
|
@@ -9,5 +9,34 @@ export interface ReviewScope {
|
|
|
9
9
|
}>;
|
|
10
10
|
unrequestedSymbols: string[];
|
|
11
11
|
coverageGaps: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Declared non-functional obligations no child T-SPEC answers, as `"<A-SPEC id> [<tag>]"`.
|
|
14
|
+
*
|
|
15
|
+
* @implements A-SPEC-235
|
|
16
|
+
* Kept BESIDE `coverageGaps`, never merged into it. Merged, the tier's reason reads
|
|
17
|
+
* "coverage gaps (3)" and cannot say whether a T-SPEC is missing or an obligation is unanswered —
|
|
18
|
+
* and the operator's next action differs: write a T-SPEC, versus tag one case.
|
|
19
|
+
*/
|
|
20
|
+
obligationGaps: string[];
|
|
12
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Obligations declared by the parent H-SPEC of an impacted A-SPEC that no child T-SPEC answers.
|
|
24
|
+
*
|
|
25
|
+
* @implements A-SPEC-235
|
|
26
|
+
* WHY THIS EXISTS. `coverageGaps` already forces full regression, but it asks only whether a
|
|
27
|
+
* T-SPEC DOCUMENT exists. A T-SPEC that exists and does not test the obligation its H-SPEC declared
|
|
28
|
+
* is not a gap under that definition, and measured by keyword over the corpus, 14 of the 24 H-SPECs
|
|
29
|
+
* declaring an obligation are in exactly that state.
|
|
30
|
+
*
|
|
31
|
+
* Fulfilment is judged by `unmetObligations` and nowhere else. A second judgement path drifts from
|
|
32
|
+
* the first; this repository has paid for that with `isTestFile` (two copies, and a Python file was
|
|
33
|
+
* excluded from the graph as a test AND gated as production at the same time) and with a restated
|
|
34
|
+
* section-splitting regex that returned an empty section while the check using it went green.
|
|
35
|
+
*
|
|
36
|
+
* WHAT IT DOES NOT SEE. Obligations written as prose rather than with the tag grammar — the other
|
|
37
|
+
* 105 approved documents. That is opt-in by design: introducing the rule and repairing the corpus in
|
|
38
|
+
* one slice makes it impossible to tell which half failed. Nor whether a case tagged `[perf]`
|
|
39
|
+
* exercises anything; correspondence is checked, meaning is not.
|
|
40
|
+
*/
|
|
41
|
+
export declare function obligationGapsFor(specs: Spec[], impactedSpecIds: string[]): string[];
|
|
13
42
|
export declare function computeReviewScope(graph: RtmGraph, specs: Spec[], changedSymbols: string[]): ReviewScope;
|
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.obligationGapsFor = obligationGapsFor;
|
|
3
4
|
exports.computeReviewScope = computeReviewScope;
|
|
4
5
|
const rtm_builder_1 = require("../rtm/rtm-builder");
|
|
6
|
+
const nonfunctional_1 = require("../spec/nonfunctional");
|
|
7
|
+
/**
|
|
8
|
+
* Obligations declared by the parent H-SPEC of an impacted A-SPEC that no child T-SPEC answers.
|
|
9
|
+
*
|
|
10
|
+
* @implements A-SPEC-235
|
|
11
|
+
* WHY THIS EXISTS. `coverageGaps` already forces full regression, but it asks only whether a
|
|
12
|
+
* T-SPEC DOCUMENT exists. A T-SPEC that exists and does not test the obligation its H-SPEC declared
|
|
13
|
+
* is not a gap under that definition, and measured by keyword over the corpus, 14 of the 24 H-SPECs
|
|
14
|
+
* declaring an obligation are in exactly that state.
|
|
15
|
+
*
|
|
16
|
+
* Fulfilment is judged by `unmetObligations` and nowhere else. A second judgement path drifts from
|
|
17
|
+
* the first; this repository has paid for that with `isTestFile` (two copies, and a Python file was
|
|
18
|
+
* excluded from the graph as a test AND gated as production at the same time) and with a restated
|
|
19
|
+
* section-splitting regex that returned an empty section while the check using it went green.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT DOES NOT SEE. Obligations written as prose rather than with the tag grammar — the other
|
|
22
|
+
* 105 approved documents. That is opt-in by design: introducing the rule and repairing the corpus in
|
|
23
|
+
* one slice makes it impossible to tell which half failed. Nor whether a case tagged `[perf]`
|
|
24
|
+
* exercises anything; correspondence is checked, meaning is not.
|
|
25
|
+
*/
|
|
26
|
+
function obligationGapsFor(specs, impactedSpecIds) {
|
|
27
|
+
if (!Array.isArray(specs) || !Array.isArray(impactedSpecIds))
|
|
28
|
+
return [];
|
|
29
|
+
const impacted = new Set(impactedSpecIds.map(stripSpecPrefix));
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const a of specs) {
|
|
32
|
+
if (a?.type !== 'A-SPEC' || !impacted.has(a.id))
|
|
33
|
+
continue;
|
|
34
|
+
// A-SPEC's parent is an H-SPEC by the type table. A broken chain is ART-2's finding; reporting
|
|
35
|
+
// it here too would give one defect two names.
|
|
36
|
+
const parent = specs.find((s) => s.type === 'H-SPEC' && (a.dependsOn ?? []).includes(s.id));
|
|
37
|
+
if (!parent)
|
|
38
|
+
continue;
|
|
39
|
+
const tspecs = specs.filter((s) => s.type === 'T-SPEC' && (s.dependsOn ?? []).includes(a.id));
|
|
40
|
+
for (const o of (0, nonfunctional_1.unmetObligations)(parent.sections?.['Non-Functional'] ?? '', tspecs.map((t) => t.sections ?? {}))) {
|
|
41
|
+
out.push(`${a.id} [${o.tag}]`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(out)].sort();
|
|
45
|
+
}
|
|
5
46
|
const ACCEPTANCE_SECTION_BY_TYPE = {
|
|
6
47
|
'A-SPEC': 'Done When',
|
|
7
48
|
'H-SPEC': 'Acceptance Criteria',
|
|
@@ -47,11 +88,14 @@ function computeReviewScope(graph, specs, changedSymbols) {
|
|
|
47
88
|
}
|
|
48
89
|
}
|
|
49
90
|
coverageGaps.sort();
|
|
91
|
+
// @implements A-SPEC-235
|
|
92
|
+
const obligationGaps = obligationGapsFor(specs, impactedSpecs);
|
|
50
93
|
return {
|
|
51
94
|
changedSymbols: dedupedChangedSymbols,
|
|
52
95
|
impactedSpecs,
|
|
53
96
|
acceptanceCriteria,
|
|
54
97
|
unrequestedSymbols,
|
|
55
98
|
coverageGaps,
|
|
99
|
+
obligationGaps,
|
|
56
100
|
};
|
|
57
101
|
}
|
|
@@ -35,6 +35,10 @@ export interface TestScope {
|
|
|
35
35
|
reason: string;
|
|
36
36
|
/** Changed source files that resolved into NO lane (A-SPEC-130) — nonempty forces `full`. */
|
|
37
37
|
unresolvedFiles: string[];
|
|
38
|
+
/** Architecture-governing documents in the change set (A-SPEC-233) — nonempty forces `full`. */
|
|
39
|
+
architectureTouched: string[];
|
|
40
|
+
/** Declared obligations no test answers (A-SPEC-235) — nonempty forces `full`. */
|
|
41
|
+
obligationGaps: string[];
|
|
38
42
|
}
|
|
39
43
|
/**
|
|
40
44
|
* @implements A-SPEC-130
|
|
@@ -46,7 +50,47 @@ export interface TestScopeExtras {
|
|
|
46
50
|
changedTestFiles?: string[];
|
|
47
51
|
/** Changed scannable sources no lane resolved: tier becomes `full`, reason names them. */
|
|
48
52
|
unresolvedFiles?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Architecture-governing documents in the change set: tier becomes `full`, reason names them.
|
|
55
|
+
*
|
|
56
|
+
* @implements A-SPEC-233
|
|
57
|
+
* Computed by the CALLER, exactly as `coverageGaps` is. That is what keeps this function pure and
|
|
58
|
+
* therefore checkable against both synthetic fixtures and the real repository.
|
|
59
|
+
*/
|
|
60
|
+
architectureTouched?: string[];
|
|
61
|
+
/**
|
|
62
|
+
* Declared non-functional obligations no child T-SPEC answers, from `obligationGapsFor`.
|
|
63
|
+
*
|
|
64
|
+
* @implements A-SPEC-235
|
|
65
|
+
* Computed by the caller, like every other signal here — that is what keeps this function pure.
|
|
66
|
+
*/
|
|
67
|
+
obligationGaps?: string[];
|
|
49
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Architecture-governing documents, picked out of a change set.
|
|
71
|
+
*
|
|
72
|
+
* @implements A-SPEC-233
|
|
73
|
+
* WHY THIS EXISTS. `assessRisk`'s architecture axis already returns `hard-hitl` for exactly these
|
|
74
|
+
* changes — "supersedes accepted ADR(s)" and "C-SPEC Forbidden Edges / Layer Rules changed". Nothing
|
|
75
|
+
* on the test side consumed that verdict. Measured 2026-08-22 on the built dist: a change impacting
|
|
76
|
+
* A-SPEC-224 (the structural-constraint slice itself) got tier `unit`, ONE test file, while
|
|
77
|
+
* `assessRisk` returned `hard-hitl`. The most dangerous change received the narrowest test run.
|
|
78
|
+
*
|
|
79
|
+
* WHY THIS PREDICATE IS COARSER THAN THE RISK AXIS, AND WHY THAT IS RIGHT. The risk axis asks "did
|
|
80
|
+
* the CONSTRAINTS change?" and answers precisely, via `cspecConstraintChanged(current, proposed)`.
|
|
81
|
+
* That needs before-and-after content, which is not available here: the change source is not always
|
|
82
|
+
* git — `change-source.ts` also diffs against a baseline hash tree, where no "before" exists to
|
|
83
|
+
* read. So this asks the weaker question it can actually answer: "was an architecture-governing
|
|
84
|
+
* document touched?" The two are allowed to differ because their cost asymmetries are opposite. A
|
|
85
|
+
* false positive on the risk axis interrupts a human for nothing; a false positive here runs more
|
|
86
|
+
* tests. Widening is this module's standing answer to uncertainty everywhere else, and it is the
|
|
87
|
+
* answer here too.
|
|
88
|
+
*
|
|
89
|
+
* WHAT IT DOES NOT SEE. Whether the edit changed a rule or only its prose. Deletions (`changedFiles`
|
|
90
|
+
* carries added/modified/renamed; `.ax/specs`/`.ax/decisions` deletion is gated separately). Any
|
|
91
|
+
* architecture decision recorded somewhere other than these two locations.
|
|
92
|
+
*/
|
|
93
|
+
export declare function architectureGoverningFiles(changedFiles: string[]): string[];
|
|
50
94
|
/**
|
|
51
95
|
* @implements A-SPEC-130
|
|
52
96
|
* Three-lane resolution for changed files. Impact propagation is symbol-keyed, so three classes of
|