@holmes-lab/holmes-kit 0.1.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,1831 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.HandlerRefusal = void 0;
|
|
37
|
+
exports.isHandlerRefusal = isHandlerRefusal;
|
|
38
|
+
exports.makeHandlers = makeHandlers;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const os = __importStar(require("node:os"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
const crypto = __importStar(require("node:crypto"));
|
|
43
|
+
const node_child_process_1 = require("node:child_process");
|
|
44
|
+
const validator_1 = require("../spec/validator");
|
|
45
|
+
const rtm_check_1 = require("../rtm/rtm-check");
|
|
46
|
+
const test_scope_1 = require("../rtm/test-scope");
|
|
47
|
+
const gap_analyzer_1 = require("../rtm/gap-analyzer");
|
|
48
|
+
const test_runner_1 = require("../review/test-runner");
|
|
49
|
+
const test_evidence_1 = require("../review/test-evidence");
|
|
50
|
+
const localize_1 = require("../rtm/localize");
|
|
51
|
+
const phase_1 = require("../guardrail/phase");
|
|
52
|
+
const spec_types_1 = require("../spec/spec-types");
|
|
53
|
+
const legacy_format_1 = require("../spec/legacy-format");
|
|
54
|
+
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
55
|
+
const scan_cache_1 = require("../cpg/scan-cache");
|
|
56
|
+
// N7: every whole-tree scan goes through the file-key incremental cache under the PROJECT root's
|
|
57
|
+
// .ax/cpg_cache — unchanged files (content-hash hit) skip the tree-sitter parse entirely.
|
|
58
|
+
// The cache lives under the RESOLVED project root, not the raw scan root: scanning a SUBDIRECTORY
|
|
59
|
+
// otherwise littered a second `.ax/cpg_cache` under that subdirectory (measured leak — `src/.ax/`),
|
|
60
|
+
// and the .gitignore only anchors the root `.ax/cpg_cache/`. Resolving to the .ax-bearing ancestor
|
|
61
|
+
// keeps one cache per project and matches where git-ignore expects it.
|
|
62
|
+
// @implements A-SPEC-189 §13 (round 12) — `resolveProjectRoot` returns the input when it finds no
|
|
63
|
+
// marker, so a read-looking scan of any directory CREATED `.ax` there and every later resolution
|
|
64
|
+
// under that tree stopped at the minted marker. This is the harm §8 closed for `review_record`,
|
|
65
|
+
// left open on the scan path. When the store is bound, the cache is the bound project's; otherwise
|
|
66
|
+
// only an anchored answer may be written to, and an unanchored one falls back to a temp cache.
|
|
67
|
+
const cacheDirFor = (root) => {
|
|
68
|
+
const r = (0, root_1.resolveProjectRoot)(root);
|
|
69
|
+
if (r.marker !== 'given')
|
|
70
|
+
return path.join(r.root, '.ax', 'cpg_cache');
|
|
71
|
+
if (fs.existsSync(path.join(r.root, '.ax')))
|
|
72
|
+
return path.join(r.root, '.ax', 'cpg_cache');
|
|
73
|
+
return path.join(os.tmpdir(), `holmes-cpg-cache-${crypto.createHash('sha256').update(r.root).digest('hex').slice(0, 16)}`);
|
|
74
|
+
};
|
|
75
|
+
const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
|
|
76
|
+
// @implements A-SPEC-131
|
|
77
|
+
// Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
|
|
78
|
+
// the changed-file accounting) need to know what the scan could NOT ingest, not just what it did.
|
|
79
|
+
const cachedScanWithReport = (root, repoRoot = root) => {
|
|
80
|
+
const scanner = new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root)));
|
|
81
|
+
const scanned = scanner.scan(root, repoRoot);
|
|
82
|
+
return { scanned, skipped: scanner.scanSkipped() };
|
|
83
|
+
};
|
|
84
|
+
const rtm_builder_1 = require("../rtm/rtm-builder");
|
|
85
|
+
const rtm_graph_1 = require("../rtm/rtm-graph");
|
|
86
|
+
const taint_1 = require("../rtm/taint");
|
|
87
|
+
const incremental_1 = require("../rtm/incremental");
|
|
88
|
+
const hash_cache_1 = require("../cpg/hash-cache");
|
|
89
|
+
const bundler_1 = require("../context/bundler");
|
|
90
|
+
const scope_1 = require("../review/scope");
|
|
91
|
+
const review_targets_1 = require("../review/review-targets");
|
|
92
|
+
const coverage_1 = require("../review/coverage");
|
|
93
|
+
const basis_1 = require("./basis");
|
|
94
|
+
const write_target_1 = require("../guardrail/write-target");
|
|
95
|
+
const spec_store_1 = require("../spec/spec-store");
|
|
96
|
+
const findings_1 = require("../review/findings");
|
|
97
|
+
const package_1 = require("../review/package");
|
|
98
|
+
const risk_classifier_1 = require("../guardrail/risk-classifier");
|
|
99
|
+
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
100
|
+
const spec_digest_1 = require("../spec/spec-digest");
|
|
101
|
+
const spec_store_2 = require("../spec/spec-store");
|
|
102
|
+
const breaking_change_1 = require("../spec/breaking-change");
|
|
103
|
+
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
104
|
+
const ledger_store_1 = require("../governance/ledger-store");
|
|
105
|
+
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
106
|
+
const ledger_lock_1 = require("../governance/ledger-lock");
|
|
107
|
+
const decision_ledger_1 = require("../guardrail/decision-ledger");
|
|
108
|
+
/**
|
|
109
|
+
* @implements A-SPEC-189 §7 (round 10)
|
|
110
|
+
* A DELIBERATE refusal, distinguished by construction rather than by class.
|
|
111
|
+
*
|
|
112
|
+
* Round 9 converted thrown errors into `{ok:false, reason}` at the wire and told them apart with
|
|
113
|
+
* `e.constructor === Error`. Measured: Node's own fs/exec failures (ENOENT, EISDIR, EACCES) are
|
|
114
|
+
* exactly `Error`, so a CRASH became an orderly refusal — and for `review_status` that refusal had
|
|
115
|
+
* no `blocked` field at all, i.e. a gate reader's `if (out.blocked)` read undefined and passed.
|
|
116
|
+
* The check that used to stop the caller loudly (-32603) became a silent gate pass. A refusal is
|
|
117
|
+
* something a handler chooses to say; nothing infers it from a class.
|
|
118
|
+
*/
|
|
119
|
+
class HandlerRefusal extends Error {
|
|
120
|
+
holmesRefusal = true;
|
|
121
|
+
constructor(message) { super(message); this.name = 'HandlerRefusal'; }
|
|
122
|
+
}
|
|
123
|
+
exports.HandlerRefusal = HandlerRefusal;
|
|
124
|
+
/** Marker test that survives realm boundaries (jest's instanceof does not). */
|
|
125
|
+
function isHandlerRefusal(e) {
|
|
126
|
+
return !!e && typeof e === 'object' && e.holmesRefusal === true;
|
|
127
|
+
}
|
|
128
|
+
// @implements A-SPEC-128
|
|
129
|
+
const root_1 = require("../project/root");
|
|
130
|
+
const change_source_1 = require("../project/change-source");
|
|
131
|
+
const baseline_1 = require("../project/baseline");
|
|
132
|
+
const ignore_1 = require("../project/ignore");
|
|
133
|
+
// @implements A-SPEC-126
|
|
134
|
+
const scan_1 = require("../reverse/scan");
|
|
135
|
+
const draft_1 = require("../reverse/draft");
|
|
136
|
+
const anchor_1 = require("../reverse/anchor");
|
|
137
|
+
// @implements A-SPEC-128
|
|
138
|
+
// Where a project begins, for tools that only walk the tree.
|
|
139
|
+
//
|
|
140
|
+
// This REPLACES `assertRepoTopLevel`, which demanded `root === git rev-parse --show-toplevel` and
|
|
141
|
+
// threw otherwise — imposed uniformly, including on tools that never look at a diff. Measured, that
|
|
142
|
+
// made cpg_scan, issue_localize, rtm_check(root), test_run and review_scope all throw on a directory
|
|
143
|
+
// with no `.git`, while reverse_scan (which asks git only for a candidacy refinement) carried on.
|
|
144
|
+
//
|
|
145
|
+
// The hazard that guard addressed is real but belongs to the GIT CHANGE SOURCE: gitChangedFiles
|
|
146
|
+
// yields top-level-relative paths while scan(root, root) tags root-relative ones, so a disagreement
|
|
147
|
+
// silently produces wrong impact sets. `GitChangeSource` now enforces exactly that, and only there.
|
|
148
|
+
function projectRootOf(root) {
|
|
149
|
+
return (0, root_1.resolveProjectRoot)(root).root;
|
|
150
|
+
}
|
|
151
|
+
// @implements A-SPEC-191 §10 — WHERE a single-use approval is spent must not be a caller's choice.
|
|
152
|
+
// r8 measured all three escapes at once: a second `root`, a `root` naming a subdirectory, and an
|
|
153
|
+
// omitted `root` (cwd-relative) each handed the same nonce a fresh, empty ledger — 'N-RISK' opened a
|
|
154
|
+
// hard-hitl door three times over, the round-7 fix notwithstanding, and the audit line for each
|
|
155
|
+
// reopening was filed in the attacker's directory. The destination is DERIVED from the store this
|
|
156
|
+
// server was bound to (spec_approve's A-SPEC-188 principle). A server that cannot derive a project
|
|
157
|
+
// cannot promise single use at all, so it says so instead of pretending: null is a refusal, never a
|
|
158
|
+
// fallback to whatever the caller named.
|
|
159
|
+
/**
|
|
160
|
+
* @implements A-SPEC-191 §15 (round 11)
|
|
161
|
+
* Does this caller's `root` name the project this server is bound to? A tool that ADVERTISES root
|
|
162
|
+
* and neither reads nor checks it answers about somewhere else in a success shape: a single typo in
|
|
163
|
+
* `root` made review_status — the merge gate's own reader — report `blocked: false` for a project
|
|
164
|
+
* whose ledger it never opened. Returns a refusal reason, or null when the root is fine.
|
|
165
|
+
*/
|
|
166
|
+
function foreignRootReason(store, root) {
|
|
167
|
+
if (typeof root !== 'string' || root === '' || !(store instanceof spec_store_1.LocalMarkdownRepository))
|
|
168
|
+
return null;
|
|
169
|
+
// An unresolvable STORE is not the caller's mistake — there is simply nothing to compare against.
|
|
170
|
+
let derived;
|
|
171
|
+
try {
|
|
172
|
+
derived = projectRootOf(store.specsRoot);
|
|
173
|
+
if (!fs.existsSync(path.join(derived, '.ax')))
|
|
174
|
+
return null; // 파생이 표지를 찾지 못하면 대조할 것이 없다
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const asked = (0, write_target_1.resolveTarget)(projectRootOf(root), '.');
|
|
181
|
+
const bound = (0, write_target_1.resolveTarget)(derived, '.');
|
|
182
|
+
if (asked === bound)
|
|
183
|
+
return null;
|
|
184
|
+
return `이 서버는 ${bound} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${root}는 ${asked} 를 가리킵니다.`
|
|
185
|
+
+ ' 다른 프로젝트에 대해 이 서버가 대신 답하지 않습니다.';
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return `root를 해석할 수 없습니다: ${String(e.message)}`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* A root the caller named that this process cannot read is the CALLER'S mistake, not a fault.
|
|
193
|
+
* @implements A-SPEC-189 §10 (round 11)
|
|
194
|
+
*/
|
|
195
|
+
function assertReadableRoot(tool, root) {
|
|
196
|
+
if (typeof root !== 'string' || root === '') {
|
|
197
|
+
throw new HandlerRefusal(`${tool}: root 는 프로젝트 루트의 절대 경로여야 합니다 (받은 값: ${JSON.stringify(root)})`);
|
|
198
|
+
}
|
|
199
|
+
let stat;
|
|
200
|
+
try {
|
|
201
|
+
stat = fs.statSync(root);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
throw new HandlerRefusal(`${tool}: ${root} 를 읽을 수 없습니다 — 그 경로가 존재하는지 확인하십시오`);
|
|
205
|
+
}
|
|
206
|
+
if (!stat.isDirectory())
|
|
207
|
+
throw new HandlerRefusal(`${tool}: ${root} 는 디렉터리가 아닙니다`);
|
|
208
|
+
}
|
|
209
|
+
function boundNonceLedger(store) {
|
|
210
|
+
if (!(store instanceof spec_store_1.LocalMarkdownRepository))
|
|
211
|
+
return null;
|
|
212
|
+
try {
|
|
213
|
+
const derived = projectRootOf(store.specsRoot);
|
|
214
|
+
// @implements A-SPEC-191 §14 (round 10) — a project root INSIDE the spec store is not a project
|
|
215
|
+
// root, whatever it is spelled. The `.ax` exclusion only covers the default layout; with
|
|
216
|
+
// `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
|
|
217
|
+
// every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
|
|
218
|
+
const storeReal = (() => { try {
|
|
219
|
+
return fs.realpathSync(store.specsRoot);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return store.specsRoot;
|
|
223
|
+
} })();
|
|
224
|
+
const derivedReal = (() => { try {
|
|
225
|
+
return fs.realpathSync(derived);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return derived;
|
|
229
|
+
} })();
|
|
230
|
+
if (derivedReal === storeReal || derivedReal.startsWith(storeReal + path.sep))
|
|
231
|
+
return null;
|
|
232
|
+
if (!fs.existsSync(path.join(derived, '.ax')))
|
|
233
|
+
return null;
|
|
234
|
+
return path.join(derived, '.ax', 'ledger', 'provenance.jsonl');
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Where the FINDINGS ledger of this server's project lives.
|
|
242
|
+
*
|
|
243
|
+
* @implements A-SPEC-189 §8 (round 11) — `review_record` joined the caller's `root` straight into a
|
|
244
|
+
* path, so a subdirectory root wrote `<root>/pkg/.ax/ledger/findings.jsonl` — and that write MINTED a
|
|
245
|
+
* project marker at `pkg`, after which the same subdirectory read as its own project (measured: the
|
|
246
|
+
* second call to the very same root was then refused as foreign). `review_status` and the Stop gate
|
|
247
|
+
* read the project's ledger, so those findings gated nothing while reporting `{recorded: N}`.
|
|
248
|
+
* The ledger's location is the bound project's fact, not an argument.
|
|
249
|
+
*/
|
|
250
|
+
function boundFindingsLedger(store, root) {
|
|
251
|
+
const projRoot = projectRootOf(root);
|
|
252
|
+
const fallback = path.join(projRoot, '.ax', 'ledger', 'findings.jsonl');
|
|
253
|
+
if (!(store instanceof spec_store_1.LocalMarkdownRepository))
|
|
254
|
+
return fallback;
|
|
255
|
+
try {
|
|
256
|
+
const derived = projectRootOf(store.specsRoot);
|
|
257
|
+
const storeReal = (() => { try {
|
|
258
|
+
return fs.realpathSync(store.specsRoot);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return store.specsRoot;
|
|
262
|
+
} })();
|
|
263
|
+
const derivedReal = (() => { try {
|
|
264
|
+
return fs.realpathSync(derived);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return derived;
|
|
268
|
+
} })();
|
|
269
|
+
if (derivedReal === storeReal || derivedReal.startsWith(storeReal + path.sep))
|
|
270
|
+
return fallback;
|
|
271
|
+
if (!fs.existsSync(path.join(derived, '.ax')))
|
|
272
|
+
return fallback;
|
|
273
|
+
return path.join(derived, '.ax', 'ledger', 'findings.jsonl');
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return fallback;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Build the change source a diff-consuming tool should use, from what the caller supplied.
|
|
281
|
+
*
|
|
282
|
+
* Precedence is explicit-over-implicit: an explicit git range wins, then a named baseline, then the
|
|
283
|
+
* default baseline — so a caller who passes nothing on a project that has been verified before still
|
|
284
|
+
* gets a scoped answer, and a caller who passes a range still gets git's rename detection.
|
|
285
|
+
*/
|
|
286
|
+
const DEFAULT_BASELINE = 'last-green';
|
|
287
|
+
function makeChangeSource(root, a) {
|
|
288
|
+
if (a.base && a.head)
|
|
289
|
+
return new change_source_1.GitChangeSource(root, a.base, a.head);
|
|
290
|
+
// @implements A-SPEC-134 — verify against the provenance chain so a tampered baseline degrades to
|
|
291
|
+
// full scope instead of narrowing against a forged reference point.
|
|
292
|
+
return new change_source_1.SnapshotChangeSource(root, a.since ?? DEFAULT_BASELINE, {
|
|
293
|
+
isIgnored: (p) => (0, ignore_1.loadIgnore)(root).isIgnored(p),
|
|
294
|
+
verify: path.join(root, '.ax', 'ledger', 'provenance.jsonl'),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// ContentSource factory shared by context_bundle and review_prepare: specMap
|
|
298
|
+
// SPEC:<id> -> title + section text, codeMap CODE:<qn>@<sourcePath> -> signature,
|
|
299
|
+
// merged `?? null`.
|
|
300
|
+
function buildContentSource(specs, scanned) {
|
|
301
|
+
const specMap = new Map(specs.map((s) => [
|
|
302
|
+
`SPEC:${s.id}`,
|
|
303
|
+
[s.title, ...Object.entries(s.sections).map(([k, v]) => `## ${k}\n${v}`)].join('\n'),
|
|
304
|
+
]));
|
|
305
|
+
const codeMap = new Map();
|
|
306
|
+
for (const f of scanned) {
|
|
307
|
+
for (const sym of f.symbols) {
|
|
308
|
+
// A-SPEC-121.4: key by the FULL (source_path-qualified) node id — see
|
|
309
|
+
// RtmGraph/rtm-builder's A-SPEC-121.3 note — so this matches the actual
|
|
310
|
+
// graph node id and bundle content resolves instead of silently
|
|
311
|
+
// falling through to `?? null`.
|
|
312
|
+
codeMap.set(`CODE:${sym.qualifiedName}@${f.sourcePath}`, `${sym.qualifiedName} (${sym.kind}) @ ${f.sourcePath}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return (nodeId) => specMap.get(nodeId) ?? codeMap.get(nodeId) ?? null;
|
|
316
|
+
}
|
|
317
|
+
// Shared prologue of review_scope/review_prepare (and the guard+git+scan
|
|
318
|
+
// part of rtm_reindex): guard the root, diff base..head, load specs, scan
|
|
319
|
+
// the tree, and derive the changed-files/changed-symbols sets used to scope
|
|
320
|
+
// the review/graph. `toolName` is passed through to assertRepoTopLevel so
|
|
321
|
+
// the thrown error names the actual caller.
|
|
322
|
+
async function deriveChangedContext(store, rootArg, a, toolName) {
|
|
323
|
+
const root = projectRootOf(rootArg);
|
|
324
|
+
const source = makeChangeSource(root, a);
|
|
325
|
+
const resolved = source.changes();
|
|
326
|
+
const changeSource = source.describe();
|
|
327
|
+
// UNAVAILABLE IS NOT "NOTHING CHANGED". Reading a missing reference point as an empty change set
|
|
328
|
+
// would narrow the scope against a baseline that was never taken — silently, and in the dangerous
|
|
329
|
+
// direction. The caller widens to full instead, the way computeTestScope already does when no
|
|
330
|
+
// anchored test resolves.
|
|
331
|
+
const changes = resolved ?? { added: [], modified: [], deleted: [], renamed: [] };
|
|
332
|
+
const specs = await store.list();
|
|
333
|
+
const { scanned, skipped } = cachedScanWithReport(root, root);
|
|
334
|
+
const bySourcePath = new Map(scanned.map((f) => [f.sourcePath, f]));
|
|
335
|
+
// Deletions have no post-change content to scan for symbols; renames
|
|
336
|
+
// are keyed by their post-change (`to`) path, matching scan()'s
|
|
337
|
+
// sourcePath tagging.
|
|
338
|
+
const changedFiles = [...changes.added, ...changes.modified, ...changes.renamed.map((r) => r.to)];
|
|
339
|
+
const changedSymbols = [
|
|
340
|
+
...new Set(changedFiles.flatMap((relPath) => bySourcePath.get(relPath)?.symbols.map((s) => s.qualifiedName) ?? [])),
|
|
341
|
+
];
|
|
342
|
+
// @implements A-SPEC-130
|
|
343
|
+
// Three-lane accounting: the symbol derivation above (lane 1) drops changed files that parse to
|
|
344
|
+
// zero symbols or are excluded from the scan — measured to zero out the impact set on a tree with
|
|
345
|
+
// five changed sources. Lanes 2/3 and the unresolved remainder are derived from the SAME
|
|
346
|
+
// collections; no new scan, no new I/O.
|
|
347
|
+
const account = (0, test_scope_1.accountChangedFiles)(changedFiles, (rel) => {
|
|
348
|
+
const s = bySourcePath.get(rel);
|
|
349
|
+
return s ? { implementsSpecs: s.implementsSpecs, symbolCount: s.symbols.length } : undefined;
|
|
350
|
+
});
|
|
351
|
+
// @implements A-SPEC-131
|
|
352
|
+
// A changed file the scan REPORTED as skipped is exactly a file whose impact cannot be proven —
|
|
353
|
+
// it joins the same unresolved lane, so the tier widens and the name rides in the answer. (The
|
|
354
|
+
// account already catches never-scanned scannable files; this catches ones with a stated reason.)
|
|
355
|
+
const skippedSet = new Set(skipped.map((s) => s.file));
|
|
356
|
+
const unresolvedFiles = [...new Set([...account.unresolvedFiles, ...changedFiles.filter((f) => skippedSet.has(f))])].sort();
|
|
357
|
+
return {
|
|
358
|
+
changes, changeSource, root, specs, scanned, bySourcePath, changedFiles, changedSymbols,
|
|
359
|
+
anchorImpactedSpecs: account.anchorImpactedSpecs,
|
|
360
|
+
changedTestFiles: account.changedTestFiles,
|
|
361
|
+
unresolvedFiles,
|
|
362
|
+
...(resolved === null ? { scopeFallback: 'full' } : {}),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// @implements A-SPEC-100.2
|
|
366
|
+
/**
|
|
367
|
+
* @implements A-SPEC-156
|
|
368
|
+
* The build this PROCESS loaded, resolved exactly once. Per-call hashing would buy I/O for no new
|
|
369
|
+
* information, and mtime would manufacture divergence on a checkout or a copy.
|
|
370
|
+
*/
|
|
371
|
+
const LOADED_BUILD = (0, basis_1.loadedBuildId)(path.resolve(__dirname, '..', '..', '..'));
|
|
372
|
+
/**
|
|
373
|
+
* @implements A-SPEC-156
|
|
374
|
+
* Basis is attached HERE, in one place, rather than inside each handler. Measured reasoning: 25
|
|
375
|
+
* handlers edited by hand is 25 chances to forget, and the one that forgets is invisible — which is
|
|
376
|
+
* precisely how the surface came to answer confidently with nothing backing it. The coverage test
|
|
377
|
+
* walks the tool list for the same reason.
|
|
378
|
+
*/
|
|
379
|
+
function makeHandlers(store) {
|
|
380
|
+
const raw = makeRawHandlers(store);
|
|
381
|
+
const wrapped = {};
|
|
382
|
+
for (const [name, fn] of Object.entries(raw)) {
|
|
383
|
+
wrapped[name] = (0, basis_1.withBasis)(fn, (a) => basisFor(a?.root));
|
|
384
|
+
}
|
|
385
|
+
wrapped.basis_detail = async (a) => {
|
|
386
|
+
const b = basisFor(a?.root, true);
|
|
387
|
+
return { ...b, basis: (0, basis_1.basisDigest)(b) };
|
|
388
|
+
};
|
|
389
|
+
// The cast restores the per-handler signatures the loop erased. Without it every handler collapses
|
|
390
|
+
// to `(a: any) => Promise<unknown>`, so a caller writing `h.rtm_check()` — legal before this
|
|
391
|
+
// change — stops compiling. That is an ADR-013 tier-③ break, and adding a diagnostic field is not
|
|
392
|
+
// a licence to take one.
|
|
393
|
+
return wrapped;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Collects the axes. `withDisk` reads the on-disk build id — done only for `basis_detail` so a stat
|
|
397
|
+
* is not charged to every call. The comparison itself is left to the caller: a server that rendered
|
|
398
|
+
* its own staleness verdict would be running that verdict from the stale build.
|
|
399
|
+
*/
|
|
400
|
+
function basisFor(root, withDisk = false) {
|
|
401
|
+
const ctx = { loadedBuild: LOADED_BUILD };
|
|
402
|
+
if (withDisk)
|
|
403
|
+
ctx.root = path.resolve(__dirname, '..', '..', '..');
|
|
404
|
+
if (root) {
|
|
405
|
+
// Both axes are constant-I/O by construction. A semantic file count would cost a 69ms tree walk
|
|
406
|
+
// per call (measured), and a graph rebuild more — this module must not make every tool slower in
|
|
407
|
+
// order to describe itself.
|
|
408
|
+
ctx.scanFp = () => fs.statSync(path.join(root, '.ax', 'cpg_cache', 'scan-cache.json')).size;
|
|
409
|
+
// A COUNT would have missed the divergence that matters most: editing a spec leaves the count
|
|
410
|
+
// identical, so two answers computed on different corpora would carry the same basis. Folding
|
|
411
|
+
// size and mtime in costs 3ms against 2ms (measured) — one millisecond for the axis to mean what
|
|
412
|
+
// it claims.
|
|
413
|
+
ctx.specs = () => {
|
|
414
|
+
const dir = path.join(root, '.ax', 'specs');
|
|
415
|
+
let acc = 0;
|
|
416
|
+
for (const f of fs.readdirSync(dir, { recursive: true })) {
|
|
417
|
+
try {
|
|
418
|
+
const st = fs.statSync(path.join(dir, f));
|
|
419
|
+
acc = (acc + st.size + Math.floor(st.mtimeMs)) >>> 0;
|
|
420
|
+
}
|
|
421
|
+
catch { /* a file that vanished mid-walk contributes nothing; absence is not an error here */ }
|
|
422
|
+
}
|
|
423
|
+
return acc;
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return (0, basis_1.collectBasis)(ctx);
|
|
427
|
+
}
|
|
428
|
+
function makeRawHandlers(store) {
|
|
429
|
+
const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
|
|
430
|
+
return {
|
|
431
|
+
async spec_create(a) {
|
|
432
|
+
// @implements A-SPEC-169
|
|
433
|
+
// `root` is a CONTROL argument. Without excluding it, the catch-all below stores it as
|
|
434
|
+
// frontmatter and a machine-local absolute path lands in a git-shared document. A blacklist,
|
|
435
|
+
// not a whitelist: domain fields (`priority`, `slice`, …) must keep flowing through without
|
|
436
|
+
// anyone maintaining a list of them.
|
|
437
|
+
const { type, id, title, depends_on = [], root: reqRoot, ...extra } = a;
|
|
438
|
+
// The server binds its spec store once, so a `root` naming another project cannot be honoured.
|
|
439
|
+
// Silently writing to this one is a data-integrity problem; refusing says so. Compared by
|
|
440
|
+
// resolved identity (A-SPEC-163) — `<root>//.` is the same directory, not a different project.
|
|
441
|
+
// @implements A-SPEC-189 §9 (round 11) — the question is "same PROJECT?", and this asked "does
|
|
442
|
+
// `<root>/.ax/specs` equal the bound store?" — the default layout hardcoded. On the first-class
|
|
443
|
+
// `--specs-dir docs/specs` deployment no root could ever satisfy it: measured, the project's own
|
|
444
|
+
// root was refused as another project's, so `spec_create` was unusable there. `spec_approve`
|
|
445
|
+
// learned this in round 3 and compares at project level; this is the same comparison.
|
|
446
|
+
// @implements A-SPEC-189 §16 (round 13) — the siblings all ask "did the derivation actually
|
|
447
|
+
// FIND a marker?" before speaking for a project (spec_approve, phase_status,
|
|
448
|
+
// foreignRootReason). Without it, a store whose derivation lands on itself (an absolute
|
|
449
|
+
// `--specs-dir` outside any `.ax` tree) calls itself the bound project and refuses the
|
|
450
|
+
// project's real root — the inverse defect rounds 3/9/11 fixed elsewhere.
|
|
451
|
+
// 파생은 이 분기 안에서만, 그리고 실패는 '표지를 못 찾았다'로 읽는다 — 스펙 트리를 이제
|
|
452
|
+
// 만들려는 참이라 스토어 경로가 아직 없을 수 있고, 그것이 예외가 되어선 안 된다.
|
|
453
|
+
const boundSpecsRoot = store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : null;
|
|
454
|
+
const derivedForCreate = (() => {
|
|
455
|
+
if (boundSpecsRoot === null)
|
|
456
|
+
return null;
|
|
457
|
+
try {
|
|
458
|
+
const d = projectRootOf(boundSpecsRoot);
|
|
459
|
+
return fs.existsSync(path.join(d, '.ax')) ? d : null;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
})();
|
|
465
|
+
if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate !== null) {
|
|
466
|
+
const asked = (0, write_target_1.resolveTarget)(projectRootOf(reqRoot), '.');
|
|
467
|
+
const bound = (0, write_target_1.resolveTarget)(derivedForCreate, '.');
|
|
468
|
+
if (asked !== bound) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
// A-SPEC-169: 두 경로를 모두 이름한다 — 어느 저장소가 묶여 있는지 호출자가 보아야 한다.
|
|
472
|
+
reason: `이 서버는 ${bound} 프로젝트(스펙 저장소 ${boundSpecsRoot})에 바인딩되어 있습니다`
|
|
473
|
+
+ ` — 요청한 root ${reqRoot}는 ${asked}를 가리킵니다.`
|
|
474
|
+
+ ' 다른 프로젝트를 조용히 수정하지 않기 위해 거부합니다.',
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// @implements A-SPEC-174
|
|
479
|
+
// Refuse what can never become valid; accept what is merely not filled in yet.
|
|
480
|
+
//
|
|
481
|
+
// Measured 2026-08-12 against the installed server: `REQ-7`, `hello`, `A-SPEC-7.1` and four
|
|
482
|
+
// more were all created and are all refused at approval on `bad-id`. The id IS the filename,
|
|
483
|
+
// so the author's only repair is to delete the spec and start over — after writing the body.
|
|
484
|
+
// The stubs below are the opposite case: they are placeholders BY DESIGN, and blocking
|
|
485
|
+
// approval until someone answers them is what they are for (A-SPEC-146).
|
|
486
|
+
//
|
|
487
|
+
// Every judgement here quotes SPEC_TYPES, which is what approval reads. A second statement of
|
|
488
|
+
// the rule drifts, and the drift is either this defect again or its worse inverse — refused
|
|
489
|
+
// at creation, accepted at approval.
|
|
490
|
+
const def = spec_types_1.SPEC_TYPES[type];
|
|
491
|
+
if (!def) {
|
|
492
|
+
return { ok: false, reason: `알 수 없는 스펙 타입 "${String(type)}" — 유효한 타입: ${spec_types_1.SPEC_ORDER.join(', ')}` };
|
|
493
|
+
}
|
|
494
|
+
if (!def.idRegex.test(String(id))) {
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
reason: `id "${String(id)}"는 ${def.type}의 형식 ${def.idRegex}에 맞지 않습니다 — 예: ${def.example}.`
|
|
498
|
+
+ ' 승인 시점에 거부될 값이므로 지금 거부합니다(id는 파일명이라 나중에 고칠 수 없습니다).',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
for (const pid of depends_on) {
|
|
502
|
+
// The parent is judged WITHOUT resolving it: not existing yet is a legitimate state an
|
|
503
|
+
// author reaches by creating the child first. A wrong type is wrong forever.
|
|
504
|
+
const ptype = (0, spec_types_1.specTypeOfId)(String(pid));
|
|
505
|
+
if (ptype === null) {
|
|
506
|
+
return { ok: false, reason: `depends_on "${String(pid)}"는 어떤 스펙 타입의 id 형식에도 맞지 않습니다 — 그 id를 가진 스펙은 존재할 수 없습니다.` };
|
|
507
|
+
}
|
|
508
|
+
if (!def.parents.includes(ptype)) {
|
|
509
|
+
return { ok: false, reason: `depends_on "${String(pid)}"는 ${ptype}입니다 — ${def.type}의 부모는 ${def.parents.join('|') || '없음(빈 depends_on)'}이어야 합니다.` };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// @implements A-SPEC-188
|
|
513
|
+
// Creation creates. Probed before this check existed: spec_create over an approved+sealed
|
|
514
|
+
// REQ returned {"created"} while the disk went draft / seal gone / prose gone — the sanctioned
|
|
515
|
+
// door destroying what risk_check guards every shell path against. Checked AFTER the format
|
|
516
|
+
// refusals on purpose: a malformed id must keep its own message (order is pinned by test).
|
|
517
|
+
// Legacy (typeless) documents count as existing too — overwriting one makes the store's
|
|
518
|
+
// orphan removal delete the differently-named original file along with its history.
|
|
519
|
+
const existing = (await store.list()).find((s) => s.id === id);
|
|
520
|
+
if (existing) {
|
|
521
|
+
const sealed = existing.status === 'approved' || Boolean(existing.frontmatter?.approved_digest);
|
|
522
|
+
const kind = existing.type ? `${existing.type} (status: ${existing.status})` : `옛 형식 문서 (status: ${existing.status})`;
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
reason: `${id}은(는) 이미 존재합니다 — ${kind}. `
|
|
526
|
+
+ (sealed
|
|
527
|
+
? '봉인(approved)된 문서이므로, 내용을 바꾸려면 파일을 편집한 뒤 spec_approve로 재봉인하십시오. '
|
|
528
|
+
: '그 문서를 고치려는 것이면 파일을 직접 편집하십시오. ')
|
|
529
|
+
+ '새 문서를 만들려는 것이면 다른 id를 쓰십시오. 생성은 아무것도 덮어쓰지 않습니다.',
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const sections = Object.fromEntries(def.requiredSections.map((s) => [s, spec_types_1.FIELD_PLACEHOLDER]));
|
|
533
|
+
// Stub the required FRONTMATTER too, not just the sections. Without this every created spec
|
|
534
|
+
// fails the project's own `spec_validate` on `missing-field` the moment it is written, and
|
|
535
|
+
// the author has to rediscover the per-type field list by reading spec-types.ts. Caller-
|
|
536
|
+
// supplied values win; `coverage` needs a shaped default because T-SPEC reads its keys.
|
|
537
|
+
// `source` is a citation LIST, so a bare 'TODO' string would fail the shape check the moment
|
|
538
|
+
// the spec is written. The stub is validly shaped but obviously unfilled: it validates while
|
|
539
|
+
// the REQ is drafted and blocks approval until a real origin replaces the placeholder.
|
|
540
|
+
const stub = (f) => {
|
|
541
|
+
if (f === 'coverage')
|
|
542
|
+
return { normal: false, corner: false, negative: false, boundary: false };
|
|
543
|
+
if (f === 'source')
|
|
544
|
+
return [{ kind: 'other', ref: spec_types_1.CITATION_PLACEHOLDER, note: 'cite the real origin before approval' }];
|
|
545
|
+
return spec_types_1.FIELD_PLACEHOLDER;
|
|
546
|
+
};
|
|
547
|
+
const required = Object.fromEntries(def.requiredFields.filter((f) => !(f in extra)).map((f) => [f, stub(f)]));
|
|
548
|
+
// @implements A-SPEC-146
|
|
549
|
+
// Scaffolded but not required by `validateSpec` — see SpecTypeDef.stubOnlyFields for why the
|
|
550
|
+
// gap exists. The stub value is `TODO`, which is NOT an accepted grade, so the field is
|
|
551
|
+
// visible to the author while the approval still refuses until it is actually answered.
|
|
552
|
+
// Pre-filling `none` would be easier and would make the formulaic answer the default, which is
|
|
553
|
+
// exactly what ADR-013 recorded as its revisit trigger.
|
|
554
|
+
const stubOnly = Object.fromEntries((def.stubOnlyFields ?? []).filter((f) => !(f in extra)).map((f) => [f, stub(f)]));
|
|
555
|
+
const spec = { id, type, title, status: 'draft', dependsOn: depends_on, frontmatter: { ...required, ...stubOnly, ...extra }, sections };
|
|
556
|
+
// @implements A-SPEC-188 — existence is a PATH question, not only a list() question.
|
|
557
|
+
// Round-2 review probed the gap: list() drops unparseable files, so a prose note or
|
|
558
|
+
// broken-YAML document sitting at the exact target path was invisible to the id check above
|
|
559
|
+
// and got silently overwritten — with the refusal text elsewhere promising the opposite.
|
|
560
|
+
// A file the store cannot read as this id is a file a human must look at first.
|
|
561
|
+
if (store instanceof spec_store_1.LocalMarkdownRepository) {
|
|
562
|
+
const target = store.targetPathFor(spec);
|
|
563
|
+
if (fs.existsSync(target)) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
reason: `${id}의 목적지(${target})에 파일이 이미 있는데 스토어가 이 id의 스펙으로 읽지 못합니다`
|
|
567
|
+
+ ' — 손으로 쓰던 초안이거나 깨진 문서일 수 있습니다. 사람이 확인해 옮기거나 고치기 전까지 덮어쓰지 않습니다'
|
|
568
|
+
+ ' (doctor가 읽을 수 없는 스펙 파일을 보고합니다).',
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
await store.write(spec);
|
|
574
|
+
}
|
|
575
|
+
catch (e) {
|
|
576
|
+
// The pre-write path check above makes this a narrow race backstop; the store guard is the
|
|
577
|
+
// authority and this act relays it in the same wording the other writers use.
|
|
578
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
reason: `${id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
|
|
582
|
+
+ ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
throw e;
|
|
586
|
+
}
|
|
587
|
+
return { created: id };
|
|
588
|
+
},
|
|
589
|
+
async spec_validate(a) {
|
|
590
|
+
const specs = await store.list();
|
|
591
|
+
const spec = specs.find((s) => s.id === a.id);
|
|
592
|
+
if (!spec)
|
|
593
|
+
return { ok: false, findings: [{ level: 'error', code: 'not-found', message: a.id }] };
|
|
594
|
+
return (0, validator_1.validateSpec)(spec, resolver(specs));
|
|
595
|
+
},
|
|
596
|
+
/**
|
|
597
|
+
* @implements A-SPEC-132
|
|
598
|
+
* Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
|
|
599
|
+
* reversal of "no approval tool exists" (which promote-slice documented while it was true) —
|
|
600
|
+
* the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
|
|
601
|
+
* the SERVER-environment approval: nothing in the request payload can substitute, because the
|
|
602
|
+
* agent authors the payload and the operator authors the environment.
|
|
603
|
+
*/
|
|
604
|
+
/**
|
|
605
|
+
* @implements A-SPEC-184
|
|
606
|
+
* Raise ONE named document from an older spec format to the current one.
|
|
607
|
+
*
|
|
608
|
+
* The compatibility policy this implements: older documents are read and left alone by default,
|
|
609
|
+
* and rise only when a human points at the one they intend to use. There is deliberately no bulk
|
|
610
|
+
* path — 155 documents here and 90 in the measured adoption target, and which of them are still
|
|
611
|
+
* live specifications is a judgement only a person holds.
|
|
612
|
+
*
|
|
613
|
+
* It declares a KIND. It does not confer approval: 37 of this repository's legacy documents read
|
|
614
|
+
* `status: Approved`, and carrying that across would mint approvals that never passed the sealing
|
|
615
|
+
* act. The old value is preserved as evidence and the document restarts at `draft`.
|
|
616
|
+
*
|
|
617
|
+
* Takes no `root`: the store is bound at server construction, exactly as `spec_create` is.
|
|
618
|
+
*/
|
|
619
|
+
async spec_upgrade(a) {
|
|
620
|
+
// @implements A-SPEC-188 — same read-then-write shape as spec_approve, so the same window
|
|
621
|
+
// AND the same duplicate hazard: read() resolves the last-walked copy, and the write's orphan
|
|
622
|
+
// removal would delete the other. Refuse rather than pick a side.
|
|
623
|
+
if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
|
|
624
|
+
return {
|
|
625
|
+
ok: false,
|
|
626
|
+
reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 보강 전에 거부합니다.`
|
|
627
|
+
+ ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const cur = await store.read(a.id);
|
|
631
|
+
if (!cur)
|
|
632
|
+
return { ok: false, reason: `spec ${a.id} not found` };
|
|
633
|
+
const spec = cur.spec;
|
|
634
|
+
const plan = (0, legacy_format_1.upgradePlan)(spec);
|
|
635
|
+
if (!plan) {
|
|
636
|
+
const why = (0, legacy_format_1.legacyMessage)(spec);
|
|
637
|
+
// No plan for two opposite reasons. Already current is success and writes nothing — a second
|
|
638
|
+
// run must not churn the file. An unsupported kind is a refusal, and its wording must not
|
|
639
|
+
// send the holder after a type that does not exist.
|
|
640
|
+
return why ? { ok: false, reason: why } : { ok: true, upgraded: false, reason: `${a.id}은(는) 이미 현행 양식입니다` };
|
|
641
|
+
}
|
|
642
|
+
const upgraded = {
|
|
643
|
+
...spec,
|
|
644
|
+
type: plan.type,
|
|
645
|
+
status: 'draft',
|
|
646
|
+
frontmatter: (0, legacy_format_1.upgradedFrontmatter)(spec, plan),
|
|
647
|
+
};
|
|
648
|
+
try {
|
|
649
|
+
await store.write(upgraded, { expectedVersion: cur.version });
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
if (e instanceof spec_store_2.SpecVersionConflictError) {
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
reason: `보강 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 시도하십시오.`
|
|
656
|
+
+ ' 이번 보강은 아무것도 쓰지 않았습니다.',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
// @implements A-SPEC-188 — the upgrade RELOCATES by design (legacy filename → canonical),
|
|
660
|
+
// so an unreadable file at the canonical path is the store's occupied-target refusal.
|
|
661
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
662
|
+
return {
|
|
663
|
+
ok: false,
|
|
664
|
+
reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
|
|
665
|
+
+ ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오. 이번 보강은 아무것도 쓰지 않았습니다.',
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
throw e;
|
|
669
|
+
}
|
|
670
|
+
// Report what still stands between this document and approval. Silence would read as "done",
|
|
671
|
+
// and a legacy document typically lacks most required sections.
|
|
672
|
+
const after = await store.list();
|
|
673
|
+
const remaining = (0, validator_1.validateSpec)(upgraded, resolver(after)).findings.filter((f) => f.level === 'error');
|
|
674
|
+
return { ok: true, upgraded: true, type: plan.type, legacyStatus: plan.legacyStatus, remaining };
|
|
675
|
+
},
|
|
676
|
+
async spec_approve(a) {
|
|
677
|
+
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
678
|
+
let approval;
|
|
679
|
+
try {
|
|
680
|
+
approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
approval = undefined;
|
|
684
|
+
}
|
|
685
|
+
// covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
|
|
686
|
+
// expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
|
|
687
|
+
// the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
|
|
688
|
+
if (!(0, risk_gate_1.approvalCovers)(approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString())) {
|
|
689
|
+
return { ok: false, reason: 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)' };
|
|
690
|
+
}
|
|
691
|
+
// @implements A-SPEC-188 — destination BEFORE seal.
|
|
692
|
+
// The old order (seal at :481, resolve the ledger at :483) produced both measured harms: a
|
|
693
|
+
// bad root left a standing seal with no ledger entry anywhere while the CALLER WAS TOLD IT
|
|
694
|
+
// FAILED, and a foreign root filed the only audit record in another project's ledger. This
|
|
695
|
+
// repository lived the first one — REQ-182's chain was approved rootless on 2026-08-13 and
|
|
696
|
+
// the missing entries were found two review rounds later.
|
|
697
|
+
//
|
|
698
|
+
// The destination is DERIVED from the store the server was bound to (same principle as
|
|
699
|
+
// spec_create's A-SPEC-163 guard); a supplied `root` is a confirmation that must match. Any
|
|
700
|
+
// failure here is a refusal with nothing written.
|
|
701
|
+
let ledgerRoot;
|
|
702
|
+
try {
|
|
703
|
+
if (store instanceof spec_store_1.LocalMarkdownRepository) {
|
|
704
|
+
// Compared at PROJECT level, through the same walk-up the old code used: a root pointing
|
|
705
|
+
// anywhere INSIDE this project (the specs dir, a subdirectory) resolves to the same
|
|
706
|
+
// project and is accepted — round-2 review caught the first cut refusing those with a
|
|
707
|
+
// message that printed the identical path on both sides. Only a root resolving to a
|
|
708
|
+
// DIFFERENT project is refused, and the message names both projects.
|
|
709
|
+
//
|
|
710
|
+
// Round-3 caught the fallback: resolveProjectRoot returns its INPUT when no .ax ancestor
|
|
711
|
+
// exists, so a custom HOLMES_SPECS outside any .ax tree "derived" the specs dir itself as
|
|
712
|
+
// the project — a rootless approval then minted .ax/ledger INSIDE the spec store, and the
|
|
713
|
+
// correct explicit root was refused as a "different project". Derivation only counts when
|
|
714
|
+
// the walk actually found a marker; otherwise the old contract stands: root is required
|
|
715
|
+
// and names the project.
|
|
716
|
+
const derived = projectRootOf(store.specsRoot);
|
|
717
|
+
const derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
|
|
718
|
+
if (derivationFoundMarker) {
|
|
719
|
+
ledgerRoot = derived;
|
|
720
|
+
if (typeof a.root === 'string' && a.root !== '') {
|
|
721
|
+
const askedProject = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
|
|
722
|
+
const boundProject = (0, write_target_1.resolveTarget)(ledgerRoot, '.');
|
|
723
|
+
if (askedProject !== boundProject) {
|
|
724
|
+
return {
|
|
725
|
+
ok: false,
|
|
726
|
+
reason: `이 서버는 ${boundProject} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${askedProject} 프로젝트를 가리킵니다.`
|
|
727
|
+
+ ' 다른 프로젝트의 원장에 기록하지 않기 위해 봉인 전에 거부합니다.',
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
else if (typeof a.root === 'string' && a.root !== '') {
|
|
733
|
+
ledgerRoot = projectRootOf(a.root);
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
reason: '스토어 위치에서 프로젝트를 파생할 수 없습니다(.ax 상위 디렉터리 없음) — 원장을 어디에 둘지 알 수 없어 봉인 전에 거부합니다. root를 지정하십시오.',
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
if (typeof a.root !== 'string' || a.root === '') {
|
|
744
|
+
return { ok: false, reason: 'root가 없고 스토어에서 원장 위치를 파생할 수도 없습니다 — 봉인 전에 거부합니다. root를 지정하십시오.' };
|
|
745
|
+
}
|
|
746
|
+
ledgerRoot = projectRootOf(a.root);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch (e) {
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
reason: `원장 위치를 확정할 수 없어 봉인 전에 거부합니다: ${String(e.message)}.`
|
|
753
|
+
+ ' 올바른 root를 지정하거나, 파일 스토어에 바인딩된 서버에서는 root를 생략하십시오.',
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const specs = await store.list();
|
|
757
|
+
// @implements A-SPEC-188 — with DUPLICATE ids the tool cannot know which copy is canonical:
|
|
758
|
+
// read() resolves the last-walked file while list().find sees the first, so approving would
|
|
759
|
+
// seal a stray's content and delete the canonical file — success reported, edit destroyed
|
|
760
|
+
// (round-2 review reproduced exactly that). Duplicates are a recognized invalid state
|
|
761
|
+
// (doctor detects them); the act refuses rather than picking a side.
|
|
762
|
+
// @implements A-SPEC-188 — the spec this act validates and seals is the one read NOW, with
|
|
763
|
+
// its version captured for the optimistic write below. Basing the candidate on the list()
|
|
764
|
+
// element instead leaves a window in which an external edit is silently destroyed and the
|
|
765
|
+
// STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
|
|
766
|
+
const cur = await store.read(a.id);
|
|
767
|
+
if (!cur)
|
|
768
|
+
return { ok: false, reason: `spec ${a.id} not found` };
|
|
769
|
+
const spec = cur.spec;
|
|
770
|
+
// @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
|
|
771
|
+
// PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
|
|
772
|
+
// walking first, the child's parent_digests sealed the stray's (forged) digest — the very
|
|
773
|
+
// snapshot drift detection compares against — while the refusal principle stated one field
|
|
774
|
+
// away was "the tool does not pick a copy". Refuse for every ambiguous id in the act.
|
|
775
|
+
const ambiguous = [a.id, ...spec.dependsOn].filter((id) => specs.filter((s) => s.id === id).length > 1);
|
|
776
|
+
if (ambiguous.length > 0) {
|
|
777
|
+
return {
|
|
778
|
+
ok: false,
|
|
779
|
+
reason: `${[...new Set(ambiguous)].join(', ')}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 전에 거부합니다.`
|
|
780
|
+
+ ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 승인하십시오.',
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
// @implements A-SPEC-168
|
|
784
|
+
// A child may not be approved under a parent that is not. The chain's meaning is that each
|
|
785
|
+
// layer is justified by the one above it, and measured 2026-08-08 that failed in four places —
|
|
786
|
+
// including A-SPEC-100.1/100.2, the core MCP guardrail, whose design H-SPEC-100 nobody ever
|
|
787
|
+
// approved. The obligation is on the ACT: existing approvals are untouched, because making it
|
|
788
|
+
// a state rule is what REQ-146 measured turning 302 specs into violations at once.
|
|
789
|
+
// @implements A-SPEC-182
|
|
790
|
+
// Shared with the gate rather than restated. Adversarial review found this check living ONLY
|
|
791
|
+
// here: the gate reported "nothing blocks" for a draft under a draft parent — the modal state
|
|
792
|
+
// of top-down authoring — and the author learned otherwise only by attempting the approval.
|
|
793
|
+
const parentIssues = (0, approval_blockers_1.parentBlockers)(spec, resolver(specs));
|
|
794
|
+
if (parentIssues.length > 0)
|
|
795
|
+
return { ok: false, reason: parentIssues[0] };
|
|
796
|
+
// Validate AS IT WILL BE — approved. Approval-gated checks (placeholder citations, 4-quadrant
|
|
797
|
+
// GWT) must fire NOW, not one turn after the seal exists.
|
|
798
|
+
const candidate = { ...spec, status: 'approved', frontmatter: { ...spec.frontmatter } };
|
|
799
|
+
delete candidate.frontmatter.approved_digest; // seals are recomputed by this act, never inherited
|
|
800
|
+
delete candidate.frontmatter.parent_digests;
|
|
801
|
+
const prevalidation = (0, validator_1.validateSpec)(candidate, resolver(specs));
|
|
802
|
+
const blocking = prevalidation.findings.filter((f) => f.level === 'error' && !approval_blockers_1.SUPPLIED_BY_APPROVAL.has(f.code) // this act supplies exactly those
|
|
803
|
+
);
|
|
804
|
+
if (blocking.length > 0)
|
|
805
|
+
return { ok: false, reason: 'validation errors block approval', findings: blocking };
|
|
806
|
+
// @implements A-SPEC-146
|
|
807
|
+
// ADR-013's duty is owed by the ACT, not by the document's static validity. Measured: putting
|
|
808
|
+
// this in `validateSpec`'s requiredFields produced 38 ART-3 violations and a Stop hook that
|
|
809
|
+
// blocked every turn, because all 38 governed A-SPECs are already approved. Here it constrains
|
|
810
|
+
// the future without invalidating the past — and a re-approval, which by definition means the
|
|
811
|
+
// content changed, is exactly when the question is due.
|
|
812
|
+
const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
|
|
813
|
+
if (breakingIssue)
|
|
814
|
+
return { ok: false, reason: breakingIssue };
|
|
815
|
+
// @implements A-SPEC-182
|
|
816
|
+
// A document whose prose is still the generator's placeholder must not be sealed. Measured
|
|
817
|
+
// 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
|
|
818
|
+
// `approved_digest` with all seven prose sections reading "TODO — a human writes this" —
|
|
819
|
+
// while its own first line said approving is the point a description becomes normative.
|
|
820
|
+
// Placed on the ACT, not in `validateSpec`, for the reason A-SPEC-146 measured: a static
|
|
821
|
+
// predicate turns every already-approved spec into a violation and bricks the harness.
|
|
822
|
+
const stubs = (0, approval_blockers_1.placeholderSections)(candidate);
|
|
823
|
+
if (stubs.length > 0) {
|
|
824
|
+
return { ok: false, reason: (0, approval_blockers_1.placeholderMessage)(stubs) };
|
|
825
|
+
}
|
|
826
|
+
// Parents-first: a sealed child snapshotting an unsealed parent would pin nothing. This loop
|
|
827
|
+
// exists to COLLECT the digests; the refusal inside it is now a backstop, because
|
|
828
|
+
// `parentBlockers` above already returns for the same condition with the same sentence. Kept
|
|
829
|
+
// rather than deleted so a future change to that function cannot silently let an unsealed
|
|
830
|
+
// parent through — but note the wording lives there, and only there, if it is ever edited.
|
|
831
|
+
const parentDigests = {};
|
|
832
|
+
for (const pid of spec.dependsOn) {
|
|
833
|
+
const parent = specs.find((s) => s.id === pid);
|
|
834
|
+
if (parent && parent.status === 'approved') {
|
|
835
|
+
const parentSeal = (0, spec_digest_1.sealOf)(parent).approvedDigest;
|
|
836
|
+
if (!parentSeal)
|
|
837
|
+
return { ok: false, reason: (0, approval_blockers_1.unsealedParentMessage)(pid) };
|
|
838
|
+
parentDigests[pid] = parentSeal;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// @implements A-SPEC-135
|
|
842
|
+
// Detect seal MOVEMENT before overwriting: the prior on-disk seal, if any, is what "moved from".
|
|
843
|
+
const priorDigest = (0, spec_digest_1.sealOf)(spec).approvedDigest;
|
|
844
|
+
const digest = (0, spec_digest_1.specDigest)(candidate);
|
|
845
|
+
candidate.frontmatter.approved_digest = digest;
|
|
846
|
+
candidate.frontmatter.parent_digests = parentDigests;
|
|
847
|
+
// @implements A-SPEC-188 — the seal is written only at the version this act READ. On
|
|
848
|
+
// conflict the EDIT wins and the APPROVAL loses: the edit is what a person just wrote; the
|
|
849
|
+
// approval is an act that can simply be retried. The reverse (the old behaviour) sealed
|
|
850
|
+
// stale content over a destroyed edit with no notice.
|
|
851
|
+
try {
|
|
852
|
+
await store.write(candidate, { expectedVersion: cur.version });
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
if (e instanceof spec_store_2.SpecVersionConflictError) {
|
|
856
|
+
return {
|
|
857
|
+
ok: false,
|
|
858
|
+
reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
|
|
859
|
+
+ ' 이번 승인은 아무것도 쓰지 않았습니다.',
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
// @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
|
|
863
|
+
// an H-SPEC into its subfolder). Round-3 probed that landing on an unreadable human note
|
|
864
|
+
// destroyed it with success reported; the store now refuses, and this act relays why.
|
|
865
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
866
|
+
return {
|
|
867
|
+
ok: false,
|
|
868
|
+
reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
|
|
869
|
+
+ ' 사람이 확인해 옮기거나 고친 뒤 다시 승인하십시오. 이번 승인은 아무것도 쓰지 않았습니다.',
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
throw e;
|
|
873
|
+
}
|
|
874
|
+
// @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
|
|
875
|
+
const chain = new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger'));
|
|
876
|
+
chain.append({
|
|
877
|
+
ts: new Date().toISOString(),
|
|
878
|
+
actor: approval.actor,
|
|
879
|
+
kind: 'spec-approved',
|
|
880
|
+
summary: `approved ${a.id} sealing ${digest}`,
|
|
881
|
+
inputs: [a.id, digest],
|
|
882
|
+
rationale: approval.rationale,
|
|
883
|
+
authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
|
|
884
|
+
});
|
|
885
|
+
// @implements A-SPEC-135
|
|
886
|
+
// P4 routing signal: a re-approval whose content CHANGED (prior seal existed and differs) mints
|
|
887
|
+
// a `review-needed` entry naming the anchored source files — the exact set a targeted review must
|
|
888
|
+
// re-examine against the new content. A first approval or an idempotent re-seal is not drift and
|
|
889
|
+
// mints nothing. This ROUTES review; it never verdicts the code. Best-effort: a missed route is
|
|
890
|
+
// not a broken seal, so an append failure does not fail the approval.
|
|
891
|
+
if (priorDigest && priorDigest !== digest) {
|
|
892
|
+
try {
|
|
893
|
+
const files = (0, review_targets_1.anchoredForReview)(a.id, cachedScan(ledgerRoot, ledgerRoot), specs);
|
|
894
|
+
chain.append({
|
|
895
|
+
ts: new Date().toISOString(), actor: approval.actor, kind: 'review-needed',
|
|
896
|
+
summary: `targeted review needed: ${a.id} content moved — re-examine ${files.length} anchored file(s) against the new spec content`,
|
|
897
|
+
inputs: [a.id, priorDigest, digest, ...files],
|
|
898
|
+
rationale: approval.rationale, authorization: '',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
catch { /* a missed routing signal is not a broken seal */ }
|
|
902
|
+
}
|
|
903
|
+
return { approved: a.id, digest };
|
|
904
|
+
},
|
|
905
|
+
async spec_list(a) {
|
|
906
|
+
const specs = await store.list();
|
|
907
|
+
// @implements A-SPEC-186
|
|
908
|
+
// The parent travels with the id because a bare one cannot be read. Measured: a reader took
|
|
909
|
+
// `T-SPEC-1841` for a count of 1,841 documents. The old-habit ids this repository once held
|
|
910
|
+
// have since all been renamed or upgraded (measured 2026-08-15: zero remain), but ADOPTED
|
|
911
|
+
// repositories still write them, so the parent keeps traveling alongside.
|
|
912
|
+
//
|
|
913
|
+
// Read from `depends_on`, NEVER parsed out of the id — parsing would answer `A-SPEC-129` for
|
|
914
|
+
// a `T-SPEC-1291` that actually hangs off something else, which is the tool committing the
|
|
915
|
+
// very misreading this exists to prevent. Omitted rather than emptied when there is none:
|
|
916
|
+
// an empty value cannot be told apart from "we looked and failed".
|
|
917
|
+
const parentOf = (s) => {
|
|
918
|
+
// @implements A-SPEC-192 §6R (round 10) — the row became format-aware in the `legacy` field
|
|
919
|
+
// only; `parent` still keyed off the DECLARED type, so an old-format document that never
|
|
920
|
+
// declared one showed no parent even when its depends_on names one. The inferred kind
|
|
921
|
+
// answers the same question the legacy mark already asks.
|
|
922
|
+
const state = (0, legacy_format_1.formatStateOf)({ id: s.id, type: s.type });
|
|
923
|
+
const kind = s.type ?? (state.kind === 'legacy' ? state.inferred : undefined);
|
|
924
|
+
const kinds = (0, spec_types_1.specTypeDef)(kind)?.parents ?? [];
|
|
925
|
+
return s.dependsOn?.find((d) => kinds.some((k) => d.startsWith(`${k}-`)));
|
|
926
|
+
};
|
|
927
|
+
// @implements A-SPEC-192 — a non-canonical status must not dress like a governed row:
|
|
928
|
+
// measured 155 of 483 rows ('Approved', 'Proposed', 'deprecated'…) rendered identically to canonical
|
|
929
|
+
// ones, and an adopter read capital-A 'Approved' as an approval. Absence of the key marks the
|
|
930
|
+
// canonical row (the same omission convention as `parent` above). The set derives from the
|
|
931
|
+
// SPEC_STATUSES runtime list — a hand-copy here was a second truth the compiler cannot police.
|
|
932
|
+
const CANONICAL_STATUSES = new Set(spec_types_1.SPEC_STATUSES);
|
|
933
|
+
return {
|
|
934
|
+
specs: specs
|
|
935
|
+
.filter((s) => !a?.type || s.type === a.type)
|
|
936
|
+
.map((s) => {
|
|
937
|
+
const parent = parentOf(s);
|
|
938
|
+
// @implements A-SPEC-192 §5R (round 9) — the mark asks 'is this row a GOVERNED spec?',
|
|
939
|
+
// and status alone could not answer it: an old-format document that never declared a
|
|
940
|
+
// `type` came through with a lone `approved` and dressed exactly like a canonical row —
|
|
941
|
+
// the misreading this REQ exists to prevent. Format and status are both grounds.
|
|
942
|
+
const legacyFormat = (0, legacy_format_1.formatStateOf)({ id: s.id, type: s.type }).kind !== 'current';
|
|
943
|
+
return {
|
|
944
|
+
id: s.id, type: s.type, status: s.status,
|
|
945
|
+
...(parent ? { parent } : {}),
|
|
946
|
+
...(CANONICAL_STATUSES.has(s.status) && !legacyFormat ? {} : { legacy: true }),
|
|
947
|
+
};
|
|
948
|
+
}),
|
|
949
|
+
};
|
|
950
|
+
},
|
|
951
|
+
async spec_next() {
|
|
952
|
+
const specs = await store.list();
|
|
953
|
+
// 다음에 작성 가능한 첫 미승인 단계
|
|
954
|
+
for (const t of spec_types_1.SPEC_ORDER) {
|
|
955
|
+
const has = specs.some((s) => s.type === t && s.status === 'approved');
|
|
956
|
+
if (!has)
|
|
957
|
+
return { next: t };
|
|
958
|
+
}
|
|
959
|
+
return { next: null };
|
|
960
|
+
},
|
|
961
|
+
async rtm_check(a) {
|
|
962
|
+
const specs = await store.list();
|
|
963
|
+
// rtm_check runs on the GOVERNED set only (excludes archived legacy +
|
|
964
|
+
// JOB specs, which otherwise flood orphan/dangling results; see
|
|
965
|
+
// ADR-010).
|
|
966
|
+
const issues = (0, rtm_check_1.rtmCheck)((0, spec_types_1.filterGoverned)(specs));
|
|
967
|
+
// @implements A-SPEC-146 — a REPORT, not a verdict: ADR-013 named formulaic `none` a
|
|
968
|
+
// revisit trigger for a human, and `(unset)` shows how far the corpus has converged.
|
|
969
|
+
const breakingChangeDistribution_ = (0, breaking_change_1.breakingChangeDistribution)((0, spec_types_1.filterGoverned)(specs));
|
|
970
|
+
if (a?.root) {
|
|
971
|
+
// @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
|
|
972
|
+
// advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
|
|
973
|
+
const root = projectRootOf(a.root);
|
|
974
|
+
// Citation drift: a REQ's cited source may have been edited since it was read, which silently
|
|
975
|
+
// invalidates the requirement derived from it. Only refs that resolve to a file INSIDE the
|
|
976
|
+
// repo are re-hashed — an unresolvable ref (external ticket, no adapter configured) is not
|
|
977
|
+
// evidence of drift, and a path escaping the repo is not ours to read.
|
|
978
|
+
const readLocal = (ref) => {
|
|
979
|
+
const abs = path.resolve(root, ref);
|
|
980
|
+
if (!abs.startsWith(path.resolve(root) + path.sep))
|
|
981
|
+
return null;
|
|
982
|
+
try {
|
|
983
|
+
return fs.readFileSync(abs, 'utf8');
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
for (const s of specs) {
|
|
990
|
+
if (s.type !== 'REQ' || s.frontmatter.source == null)
|
|
991
|
+
continue;
|
|
992
|
+
for (const d of (0, validator_1.verifyCitationDigests)(s.frontmatter.source, readLocal)) {
|
|
993
|
+
issues.push({ level: d.level, code: d.code, message: `${s.id}: ${d.message}` });
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const scanned = cachedScan(root, root);
|
|
997
|
+
// checkImplements checks @implements A-SPEC refs against the FULL
|
|
998
|
+
// spec id set (not filterGoverned) — it's an existence check: any
|
|
999
|
+
// existing spec id is a valid anchor target.
|
|
1000
|
+
issues.push(...(0, rtm_check_1.checkImplements)(scanned, specs));
|
|
1001
|
+
// L6 convergence gaps (ADVISORY, not issues): where implementation and the governed spec set
|
|
1002
|
+
// have drifted apart — unimplemented approved specs, unanchored source, untested specs. Feeds
|
|
1003
|
+
// the convergence loop's "pick next work"; an unimplemented spec is normal mid-development.
|
|
1004
|
+
const gaps = (0, gap_analyzer_1.computeConvergenceGaps)(scanned, (0, spec_types_1.filterGoverned)(specs), (0, test_scope_1.scanTestAnchors)(root));
|
|
1005
|
+
return { issues, gaps, breakingChangeDistribution: breakingChangeDistribution_ };
|
|
1006
|
+
}
|
|
1007
|
+
return { issues, breakingChangeDistribution: breakingChangeDistribution_ };
|
|
1008
|
+
},
|
|
1009
|
+
/**
|
|
1010
|
+
* Pin a REQ's citations: compute the content digest of every cited source that resolves inside
|
|
1011
|
+
* the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
|
|
1012
|
+
* always derived from what is actually on disk.
|
|
1013
|
+
*
|
|
1014
|
+
* Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
|
|
1015
|
+
* re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
|
|
1016
|
+
* detector into a concealer.
|
|
1017
|
+
*/
|
|
1018
|
+
async citation_pin(a) {
|
|
1019
|
+
// @implements A-SPEC-189 §15 (round 13) — every sibling that reads or writes on behalf of a
|
|
1020
|
+
// project refuses a foreign root (spec_create, phase_status, review_record, review_status,
|
|
1021
|
+
// spec_approve). `citation_pin` did not, and it SEALS what it read: the sha256 of another
|
|
1022
|
+
// project's file was written into this store's REQ frontmatter as evidence, permanently —
|
|
1023
|
+
// the design deliberately never overwrites an existing digest.
|
|
1024
|
+
const foreignPin = foreignRootReason(store, a.root);
|
|
1025
|
+
if (foreignPin !== null)
|
|
1026
|
+
return { ok: false, reason: `citation_pin: ${foreignPin}` };
|
|
1027
|
+
// @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
|
|
1028
|
+
// advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
|
|
1029
|
+
const root = projectRootOf(a.root);
|
|
1030
|
+
// @implements A-SPEC-188 — the third writer, same duplicate hazard as approve/upgrade:
|
|
1031
|
+
// read() resolves the last-walked copy and the write's orphan sweep deletes the other.
|
|
1032
|
+
if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
|
|
1033
|
+
return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 기록 전에 거부합니다.`
|
|
1036
|
+
+ ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
// @implements A-SPEC-151
|
|
1040
|
+
// `read` now yields the spec together with the version it was read at, so a write can say
|
|
1041
|
+
// "only if nobody touched this since". citation_pin uses it: the pin is derived from what was
|
|
1042
|
+
// read, and writing it back over someone else's edit would silently discard their change.
|
|
1043
|
+
const found = await store.read(a.id);
|
|
1044
|
+
if (!found)
|
|
1045
|
+
return { ok: false, reason: `spec ${a.id} not found` };
|
|
1046
|
+
const { spec, version } = found;
|
|
1047
|
+
if (spec.type !== 'REQ')
|
|
1048
|
+
return { ok: false, reason: `citations live on REQ; ${a.id} is ${spec.type}` };
|
|
1049
|
+
if (spec.frontmatter.source == null)
|
|
1050
|
+
return { ok: false, reason: `${a.id} has no source to pin` };
|
|
1051
|
+
const readLocal = (ref) => {
|
|
1052
|
+
const abs = path.resolve(root, ref);
|
|
1053
|
+
// A ref that escapes the repository is not ours to read, and following it would let a spec
|
|
1054
|
+
// pull arbitrary host files into the provenance record.
|
|
1055
|
+
if (!abs.startsWith(path.resolve(root) + path.sep))
|
|
1056
|
+
return null;
|
|
1057
|
+
try {
|
|
1058
|
+
return fs.readFileSync(abs, 'utf8');
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
const { citations, pinned, findings } = (0, validator_1.pinCitations)(spec.frontmatter.source, readLocal);
|
|
1065
|
+
const dryRun = a.dryRun !== false; // opt IN to writing
|
|
1066
|
+
if (!dryRun && pinned.length > 0) {
|
|
1067
|
+
try {
|
|
1068
|
+
try {
|
|
1069
|
+
await store.write({ ...spec, frontmatter: { ...spec.frontmatter, source: citations } }, { expectedVersion: version });
|
|
1070
|
+
}
|
|
1071
|
+
catch (e) {
|
|
1072
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
1073
|
+
return {
|
|
1074
|
+
ok: false,
|
|
1075
|
+
reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
|
|
1076
|
+
+ ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
throw e;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
if (err instanceof spec_store_2.SpecVersionConflictError) {
|
|
1084
|
+
return { ok: false, reason: `${a.id} changed while its citations were being pinned — re-read and retry (${err.message})` };
|
|
1085
|
+
}
|
|
1086
|
+
throw err;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return { ok: true, id: a.id, dryRun, pinned, findings, citations };
|
|
1090
|
+
},
|
|
1091
|
+
async phase_status(a) {
|
|
1092
|
+
// @implements A-SPEC-189 §6 — `root` was advertised and never read, so a call naming another
|
|
1093
|
+
// project (or a path that does not exist) got THIS server's corpus in a success shape and the
|
|
1094
|
+
// caller derived its phase from someone else's specs. Same anchor discipline as spec_approve.
|
|
1095
|
+
if (typeof a?.root === 'string' && a.root !== '' && store instanceof spec_store_1.LocalMarkdownRepository) {
|
|
1096
|
+
let asked;
|
|
1097
|
+
let bound;
|
|
1098
|
+
let derivationFoundMarker = false;
|
|
1099
|
+
try {
|
|
1100
|
+
asked = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
|
|
1101
|
+
const derived = projectRootOf(store.specsRoot);
|
|
1102
|
+
derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
|
|
1103
|
+
bound = (0, write_target_1.resolveTarget)(derived, '.');
|
|
1104
|
+
}
|
|
1105
|
+
catch (e) {
|
|
1106
|
+
return { ok: false, reason: `root를 해석할 수 없습니다: ${String(e.message)}` };
|
|
1107
|
+
}
|
|
1108
|
+
// @implements A-SPEC-189 §7 (round 10) — spec_approve's round-3 lesson, copied here at last:
|
|
1109
|
+
// resolveProjectRoot returns its INPUT when no `.ax` ancestor exists, so a custom
|
|
1110
|
+
// HOLMES_SPECS outside any project "derived" the spec store itself as the project and this
|
|
1111
|
+
// guard then refused the CORRECT root. Only a derivation that actually found a marker
|
|
1112
|
+
// speaks for a project.
|
|
1113
|
+
if (derivationFoundMarker && asked !== bound) {
|
|
1114
|
+
return {
|
|
1115
|
+
ok: false,
|
|
1116
|
+
reason: `이 서버는 ${bound} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${asked} 를 가리킵니다.`
|
|
1117
|
+
+ ' 다른 프로젝트의 코퍼스를 이 서버가 대신 답하지 않습니다.',
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
const specs = await store.list();
|
|
1122
|
+
return { specs: specs.map((s) => ({ id: s.id, type: s.type, status: s.status })), note: 'derive phase via phase_check' };
|
|
1123
|
+
},
|
|
1124
|
+
async phase_check(a) {
|
|
1125
|
+
const specs = await store.list();
|
|
1126
|
+
const action = a.action ?? (0, phase_1.classifyAction)(a.target);
|
|
1127
|
+
if (!action)
|
|
1128
|
+
return { decision: 'allow', note: 'unclassified target' };
|
|
1129
|
+
return (0, phase_1.phaseCheck)(action, { specs, targetAspecId: a.targetAspecId });
|
|
1130
|
+
},
|
|
1131
|
+
async cpg_scan(a) {
|
|
1132
|
+
// @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
|
|
1133
|
+
// advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
|
|
1134
|
+
const root = projectRootOf(a.root);
|
|
1135
|
+
// @implements A-SPEC-131
|
|
1136
|
+
// The skip report is part of the answer: a caller cannot distinguish "clean tree" from "tree
|
|
1137
|
+
// with casualties" by counts alone. Bounded listing (50), complete count.
|
|
1138
|
+
const { scanned, skipped } = cachedScanWithReport(root);
|
|
1139
|
+
const symbols = scanned.reduce((n, f) => n + f.symbols.length, 0);
|
|
1140
|
+
return { files: scanned.length, symbols, skipped: skipped.slice(0, 50), skippedCount: skipped.length };
|
|
1141
|
+
},
|
|
1142
|
+
/**
|
|
1143
|
+
* @implements A-SPEC-138
|
|
1144
|
+
* Call-graph taint REACHABILITY screen: source-named functions that reach sink-named functions
|
|
1145
|
+
* through call edges. A SCREENING signal that routes a security review, NOT a data-flow proof —
|
|
1146
|
+
* the result always carries the honesty envelope (kind + limits) and phrases pairs as "reaches",
|
|
1147
|
+
* never "vulnerable".
|
|
1148
|
+
*/
|
|
1149
|
+
async taint_scan(a) {
|
|
1150
|
+
const root = projectRootOf(a.root);
|
|
1151
|
+
const scanned = cachedScan(root, root);
|
|
1152
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1153
|
+
try {
|
|
1154
|
+
(0, rtm_builder_1.buildRtm)(await store.list(), scanned, g);
|
|
1155
|
+
const cfg = taint_1.DEFAULT_TAINT_CONFIG;
|
|
1156
|
+
const { pairs, truncated } = (0, taint_1.taintReachability)(g, cfg);
|
|
1157
|
+
return { kind: 'call-reachability', limits: [...taint_1.TAINT_LIMITS], maxPaths: cfg.maxPaths, truncated, pairs };
|
|
1158
|
+
}
|
|
1159
|
+
finally {
|
|
1160
|
+
g.close();
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
async test_run(a) {
|
|
1164
|
+
// Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
|
|
1165
|
+
// (what the constitution's ART-4 prefers over the syntactic count).
|
|
1166
|
+
const { root, specs, scanned, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
|
|
1167
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1168
|
+
let testScope;
|
|
1169
|
+
try {
|
|
1170
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1171
|
+
const scope = (0, scope_1.computeReviewScope)(g, specs, changedSymbols);
|
|
1172
|
+
// @implements A-SPEC-130
|
|
1173
|
+
// Lane 2 widens the impacted set with file-anchor edges the symbol walk cannot see; lanes 3
|
|
1174
|
+
// and the unresolved remainder ride in through extras. A nonempty change set can end in a
|
|
1175
|
+
// scoped run or a full run — never in "nothing to do".
|
|
1176
|
+
const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
|
|
1177
|
+
testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles });
|
|
1178
|
+
// @implements A-SPEC-128
|
|
1179
|
+
// No change set means no basis for narrowing. Widening is the same fail-safe direction
|
|
1180
|
+
// computeTestScope already takes when no anchored test resolves — never a silently narrow run.
|
|
1181
|
+
if (scopeFallback === 'full') {
|
|
1182
|
+
testScope = { ...testScope, tier: 'full', reason: `${changeSource.reason ?? 'no change set'} — cannot narrow safely, full regression` };
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
finally {
|
|
1186
|
+
g.close();
|
|
1187
|
+
}
|
|
1188
|
+
const result = (0, test_runner_1.runTestScope)(testScope, root);
|
|
1189
|
+
const anchors = (0, test_scope_1.scanTestAnchors)(root);
|
|
1190
|
+
const executedByAspec = (0, test_evidence_1.computeExecutedByAspec)(result.executedByFile ?? {}, anchors);
|
|
1191
|
+
let head = '';
|
|
1192
|
+
try {
|
|
1193
|
+
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
1194
|
+
}
|
|
1195
|
+
catch { /* non-git */ }
|
|
1196
|
+
// Record ONLY an actually-executed, GREEN run (review C4/C7): a red suite must not stand as
|
|
1197
|
+
// coverage evidence at the moment the code is broken, and a no-op run must not overwrite a real
|
|
1198
|
+
// record with a dishonest `passed: true` over an empty result.
|
|
1199
|
+
const verified = (0, baseline_1.shouldRecordBaseline)(result);
|
|
1200
|
+
if (verified) {
|
|
1201
|
+
(0, test_evidence_1.writeTestEvidence)(root, { ts: new Date().toISOString(), head, tier: testScope.tier, passed: true, executedByAspec });
|
|
1202
|
+
}
|
|
1203
|
+
// @implements A-SPEC-128
|
|
1204
|
+
// The baseline is written under EXACTLY the condition that already gates evidence: a run that
|
|
1205
|
+
// actually executed and passed. A red or skipped run must never become the reference point for
|
|
1206
|
+
// "since the last verified state" — that would silently narrow every later scope against a
|
|
1207
|
+
// state nobody verified.
|
|
1208
|
+
let baseline;
|
|
1209
|
+
if (verified) {
|
|
1210
|
+
baseline = a.mark ?? DEFAULT_BASELINE;
|
|
1211
|
+
(0, baseline_1.writeBaseline)(root, baseline, (0, change_source_1.hashTree)(root, { isIgnored: (p) => (0, ignore_1.loadIgnore)(root).isIgnored(p) }));
|
|
1212
|
+
}
|
|
1213
|
+
return { tier: testScope.tier, mode: result.mode, passed: result.passed, skipped: result.skipped,
|
|
1214
|
+
ranFiles: result.ranFiles, executedByAspec, tail: result.tail,
|
|
1215
|
+
// @implements A-SPEC-130 — the remediation rides in the answer: these are the files to anchor.
|
|
1216
|
+
unresolvedFiles: testScope.unresolvedFiles,
|
|
1217
|
+
changeSource, ...(scopeFallback ? { scopeFallback } : {}), ...(baseline ? { baselineRecorded: baseline } : {}) };
|
|
1218
|
+
},
|
|
1219
|
+
async issue_localize(a) {
|
|
1220
|
+
// N1: deterministic localization report — CPG lexical match fused with the RTM spec hop.
|
|
1221
|
+
// @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
|
|
1222
|
+
// statement: its only effect was to throw on a bad path, and the advertised contract ("a
|
|
1223
|
+
// subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
|
|
1224
|
+
// honours it, so the same argument answered about two different trees depending on which tool
|
|
1225
|
+
// was asked. Bind the derivation and use it.
|
|
1226
|
+
const root = projectRootOf(a.root);
|
|
1227
|
+
const scanned = cachedScan(root, root);
|
|
1228
|
+
const specs = await store.list();
|
|
1229
|
+
return (0, localize_1.localizeIssue)(a.issue, scanned, (0, spec_types_1.filterGoverned)(specs), a.topN ?? 10);
|
|
1230
|
+
},
|
|
1231
|
+
async rtm_impact(a) {
|
|
1232
|
+
// @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
|
|
1233
|
+
// statement: its only effect was to throw on a bad path, and the advertised contract ("a
|
|
1234
|
+
// subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
|
|
1235
|
+
// honours it, so the same argument answered about two different trees depending on which tool
|
|
1236
|
+
// was asked. Bind the derivation and use it.
|
|
1237
|
+
const root = projectRootOf(a.root);
|
|
1238
|
+
const scanned = cachedScan(root);
|
|
1239
|
+
const specs = await store.list();
|
|
1240
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1241
|
+
try {
|
|
1242
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1243
|
+
// explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
|
|
1244
|
+
// An impact set is not just a list — a broad one means "review the contract", and a consumer
|
|
1245
|
+
// that cannot tell the difference will try to bundle two hundred call sites.
|
|
1246
|
+
const { specs: impacted, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
|
|
1247
|
+
return {
|
|
1248
|
+
impacted,
|
|
1249
|
+
reachedByDepth,
|
|
1250
|
+
bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
|
|
1251
|
+
...(seedIsHub ? {
|
|
1252
|
+
breadthWarning: `a changed symbol has ${seedIsHub.callers} callers (hub threshold ${seedIsHub.threshold}) — this is a CONTRACT change; review the contract and its tests rather than bundling every impacted spec`,
|
|
1253
|
+
} : {}),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
finally {
|
|
1257
|
+
g.close(); // release native SQLite handle even if build/query throws
|
|
1258
|
+
}
|
|
1259
|
+
},
|
|
1260
|
+
async rtm_reindex(a) {
|
|
1261
|
+
// Removal/re-derivation of changedFiles/changedSymbols is not needed
|
|
1262
|
+
// here (rtm_reindex works off changes/specs/scanned/bySourcePath
|
|
1263
|
+
// directly), but the guard+diff+scan prologue is identical to
|
|
1264
|
+
// review_scope/review_prepare — see deriveChangedContext.
|
|
1265
|
+
const { root, changes, specs, scanned, bySourcePath, changeSource } = await deriveChangedContext(store, a.root, a, 'rtm_reindex');
|
|
1266
|
+
const changed = changes.added.length + changes.modified.length + changes.deleted.length + changes.renamed.length;
|
|
1267
|
+
const scanOne = (relPath) => bySourcePath.get(relPath) ?? null;
|
|
1268
|
+
// HashCache skip: for add/modify entries, if the file's current content
|
|
1269
|
+
// hash matches what's on record, the file is dropped from the set
|
|
1270
|
+
// handed to applyIncremental — its subgraph is left untouched rather
|
|
1271
|
+
// than being torn down and rebuilt for no reason (e.g. a spurious
|
|
1272
|
+
// `git diff` entry, a mode-only change, or a re-run over the same
|
|
1273
|
+
// range). Deletes and renames are always applied: a delete has no
|
|
1274
|
+
// "content" to compare, and a rename's `from` side must be removed
|
|
1275
|
+
// regardless of whether `to`'s content matches something previously
|
|
1276
|
+
// hashed under a different path.
|
|
1277
|
+
const cache = new hash_cache_1.HashCache(cacheDirFor(root)); // @implements A-SPEC-128 — project-root cache, no subdir leak
|
|
1278
|
+
const skipUnchanged = (files) => files.filter((relPath) => {
|
|
1279
|
+
let content;
|
|
1280
|
+
try {
|
|
1281
|
+
content = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
return true; // unreadable (e.g. already gone) — let applyIncremental/scanOne handle it
|
|
1285
|
+
}
|
|
1286
|
+
if (cache.unchanged(relPath, content))
|
|
1287
|
+
return false; // skip: content identical to last recorded hash
|
|
1288
|
+
cache.put(relPath, content);
|
|
1289
|
+
return true;
|
|
1290
|
+
});
|
|
1291
|
+
const changesToApply = {
|
|
1292
|
+
added: skipUnchanged(changes.added),
|
|
1293
|
+
modified: skipUnchanged(changes.modified),
|
|
1294
|
+
deleted: changes.deleted,
|
|
1295
|
+
renamed: changes.renamed,
|
|
1296
|
+
};
|
|
1297
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1298
|
+
try {
|
|
1299
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1300
|
+
// Scaffold note: `g` was just full-built at HEAD, so applying the
|
|
1301
|
+
// diff on top of it is currently an idempotent no-op in practice
|
|
1302
|
+
// (the affected subgraphs are already correct). This call exists to
|
|
1303
|
+
// exercise/validate the incremental machinery end-to-end now, ahead
|
|
1304
|
+
// of a FUTURE persisted-graph reindex — build the graph once, then
|
|
1305
|
+
// apply diffs across calls without a full rebuild — that will
|
|
1306
|
+
// actually depend on this path being correct. Not load-bearing today.
|
|
1307
|
+
(0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne });
|
|
1308
|
+
return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource };
|
|
1309
|
+
}
|
|
1310
|
+
finally {
|
|
1311
|
+
g.close(); // release native SQLite handle even if build/apply throws
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
async context_bundle(a) {
|
|
1315
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1316
|
+
if (foreign)
|
|
1317
|
+
throw new HandlerRefusal(foreign);
|
|
1318
|
+
const root = projectRootOf(a.root);
|
|
1319
|
+
const specs = await store.list();
|
|
1320
|
+
const scanned = cachedScan(root, root);
|
|
1321
|
+
const content = buildContentSource(specs, scanned);
|
|
1322
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1323
|
+
try {
|
|
1324
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1325
|
+
return new bundler_1.ContextBundler(g, content).getContextBundle(a.seedId, a.budget);
|
|
1326
|
+
}
|
|
1327
|
+
finally {
|
|
1328
|
+
g.close(); // release native SQLite handle even if bundling throws
|
|
1329
|
+
}
|
|
1330
|
+
},
|
|
1331
|
+
async review_scope(a) {
|
|
1332
|
+
// Guard+diff+scan+changedFiles/changedSymbols prologue: see
|
|
1333
|
+
// deriveChangedContext (shared with review_prepare/rtm_reindex).
|
|
1334
|
+
const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'review_scope');
|
|
1335
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1336
|
+
try {
|
|
1337
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1338
|
+
const scope = (0, scope_1.computeReviewScope)(g, specs, changedSymbols);
|
|
1339
|
+
// Intelligent regression test-scope (target-arch §5). Test files are scanned SEPARATELY for
|
|
1340
|
+
// their @implements anchors (the CPG scan excludes tests), coverage gaps feed the safety
|
|
1341
|
+
// fallback, and impactedSpecs are the graph's SPEC:-prefixed ids (stripped inside).
|
|
1342
|
+
// @implements A-SPEC-130 — the same widened set as test_run, so review packaging and test
|
|
1343
|
+
// scoping cannot disagree about what a change touches.
|
|
1344
|
+
const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
|
|
1345
|
+
const testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles });
|
|
1346
|
+
// Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
|
|
1347
|
+
// did NOT ingest (e.g. non-.ts files), computed from the same
|
|
1348
|
+
// changedFiles/scanned already derived above — no new git/scan calls.
|
|
1349
|
+
const { unscanned: unscannedChangedFiles } = (0, coverage_1.partitionChangedFiles)(changedFiles, scanned.map((f) => f.sourcePath));
|
|
1350
|
+
return { ...scope, unscannedChangedFiles, testScope, changeSource, ...(scopeFallback ? { scopeFallback } : {}) };
|
|
1351
|
+
}
|
|
1352
|
+
finally {
|
|
1353
|
+
g.close(); // release native SQLite handle even if scope computation throws
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
async review_prepare(a) {
|
|
1357
|
+
// Guard+diff+scan+changedFiles/changedSymbols prologue: see
|
|
1358
|
+
// deriveChangedContext (shared with review_scope/rtm_reindex).
|
|
1359
|
+
const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback } = await deriveChangedContext(store, a.root, a, 'review_prepare');
|
|
1360
|
+
// ContentSource factory shared with context_bundle: see buildContentSource.
|
|
1361
|
+
const content = buildContentSource(specs, scanned);
|
|
1362
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1363
|
+
try {
|
|
1364
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1365
|
+
const pkg = (0, package_1.assembleReviewPackage)(g, specs, changedSymbols, content, a.budget ?? 4000);
|
|
1366
|
+
// Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
|
|
1367
|
+
// did NOT ingest, computed from the same changedFiles/scanned already
|
|
1368
|
+
// derived above — no new git/scan calls.
|
|
1369
|
+
const { unscanned: unscannedChangedFiles } = (0, coverage_1.partitionChangedFiles)(changedFiles, scanned.map((f) => f.sourcePath));
|
|
1370
|
+
return { package: pkg, unscannedChangedFiles, changeSource, ...(scopeFallback ? { scopeFallback } : {}) };
|
|
1371
|
+
}
|
|
1372
|
+
finally {
|
|
1373
|
+
g.close(); // release native SQLite handle even if assembly throws
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
async review_record(a) {
|
|
1377
|
+
// @implements A-SPEC-189 §8 (round 11) — §15 shut the door the merge gate READS through and
|
|
1378
|
+
// left the one it is WRITTEN through wide open. Measured: `review_record` joined the caller's
|
|
1379
|
+
// `root` straight into a path and created `.ax/ledger/findings.jsonl` in any directory on the
|
|
1380
|
+
// machine, answering `{recorded: N}` — while `review_status`, bound to this server's project,
|
|
1381
|
+
// could never see those findings. An open critical filed at the wrong root is worse than none:
|
|
1382
|
+
// it reads as recorded and gates nothing. The two doors now ask the same question.
|
|
1383
|
+
const foreignWrite = foreignRootReason(store, a.root);
|
|
1384
|
+
if (foreignWrite !== null)
|
|
1385
|
+
throw new HandlerRefusal(`review_record: ${foreignWrite}`);
|
|
1386
|
+
// MCP input is unvalidated JSON: an out-of-enum severity or status
|
|
1387
|
+
// (e.g. a typo like "blocker") would land in the ledger as a stray
|
|
1388
|
+
// value that review_status's counts never match, so it counts toward
|
|
1389
|
+
// neither its own bucket nor `blocked` — a mislabeled critical could
|
|
1390
|
+
// silently pass the gate. Validate up front so the gate's counts stay
|
|
1391
|
+
// trustworthy.
|
|
1392
|
+
const validSeverities = new Set(['critical', 'important', 'minor']);
|
|
1393
|
+
const validStatuses = new Set(['open', 'resolved']);
|
|
1394
|
+
for (const f of a.findings) {
|
|
1395
|
+
if (!validSeverities.has(f.severity)) {
|
|
1396
|
+
throw new HandlerRefusal(`review_record: finding ${f.id} has invalid severity ${JSON.stringify(f.severity)} (must be one of critical|important|minor)`);
|
|
1397
|
+
}
|
|
1398
|
+
if (!validStatuses.has(f.status)) {
|
|
1399
|
+
throw new HandlerRefusal(`review_record: finding ${f.id} has invalid status ${JSON.stringify(f.status)} (must be one of open|resolved)`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// @implements A-SPEC-191 (§4e) — the ledger's meaning is guarded at RECORD time, where the
|
|
1403
|
+
// collision actually happens. Measured: conventional ids (C1…) recur across review rounds, so
|
|
1404
|
+
// one round's resolve of "C1" silently lifted the gate over ANOTHER round's unfixed critical;
|
|
1405
|
+
// and a severity-downgrade re-open cleared ART-7 with no resolution at all. Both die at one
|
|
1406
|
+
// door: an id whose latest state is open cannot be opened again — resolve first, then
|
|
1407
|
+
// re-record. Lifting an open CRITICAL requires the out-of-band approval channel (ART-5's
|
|
1408
|
+
// principle): the blocked party must not be able to pull its own teeth in-band. A resolved
|
|
1409
|
+
// record for an id never opened lifts nothing and stays approval-free (found-and-fixed audit
|
|
1410
|
+
// records keep working).
|
|
1411
|
+
// The guard and the append hold ONE lock (round-6: the read→judge→append section was a
|
|
1412
|
+
// cross-process TOCTOU — two sessions both recorded open under the same id, silencing an
|
|
1413
|
+
// open critical at both readers; consumeNonceExclusively already earned this discipline).
|
|
1414
|
+
const findingsFile = boundFindingsLedger(store, a.root);
|
|
1415
|
+
(0, ledger_lock_1.withLedgerLock)(findingsFile, () => {
|
|
1416
|
+
const ledger = new findings_1.FindingsLedger(findingsFile);
|
|
1417
|
+
const latest = new Map();
|
|
1418
|
+
const liftedCriticals = [];
|
|
1419
|
+
for (const f of ledger.list())
|
|
1420
|
+
latest.set(f.id, f);
|
|
1421
|
+
for (const f of a.findings) {
|
|
1422
|
+
if (!f.id || !f.id.trim()) {
|
|
1423
|
+
throw new HandlerRefusal('review_record: finding id 가 비어 있습니다 — 모든 발견은 원장에서 유일하게 식별될 id 가 필요합니다');
|
|
1424
|
+
}
|
|
1425
|
+
// Round-5: pre-round-4 ads declared `message`, but the ledger whitelist persists only
|
|
1426
|
+
// `summary` — clients following the OLD ad lost their text with zero signal. A pointed
|
|
1427
|
+
// refusal turns that silent loss into the one-line fix it needs.
|
|
1428
|
+
// Round-9: `=== undefined` let `summary: null` through, so the ledger dropped the text
|
|
1429
|
+
// and the caller saw success — the very silence this guard exists to end. Absence is
|
|
1430
|
+
// null OR undefined here, as everywhere else in this validator's vocabulary.
|
|
1431
|
+
if ('message' in f && (f.summary === undefined || f.summary === null)) {
|
|
1432
|
+
throw new HandlerRefusal(`review_record: finding ${f.id} 의 message 는 폐지된 광고 키입니다 — 원장은 summary 만 보존합니다. 같은 내용을 summary 로 보내십시오`);
|
|
1433
|
+
}
|
|
1434
|
+
const last = latest.get(f.id);
|
|
1435
|
+
if (f.status === 'open' && last?.status === 'open') {
|
|
1436
|
+
throw new HandlerRefusal(`review_record: id ${f.id} 는 이미 미해소(open) 상태입니다 — 다른 발견이면 새 id 를 쓰고, 같은 발견의 갱신이면 먼저 resolved 를 기록한 뒤 재기록하십시오 (id 충돌이 남의 critical 을 침묵시키는 것을 막는 문입니다)`);
|
|
1437
|
+
}
|
|
1438
|
+
if (f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical') {
|
|
1439
|
+
const raw = process.env.HOLMES_APPROVAL;
|
|
1440
|
+
let approval;
|
|
1441
|
+
try {
|
|
1442
|
+
approval = raw ? JSON.parse(raw) : undefined;
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
approval = undefined;
|
|
1446
|
+
}
|
|
1447
|
+
// covers, not merely well-formed (round-2): A-SPEC-133 built the seam so one token is
|
|
1448
|
+
// not a master key — an EXPIRED or elsewhere-scoped approval must not lift a critical.
|
|
1449
|
+
// An unscoped {actor,token,rationale} stays the operator's session key (unchanged).
|
|
1450
|
+
const nowTs = new Date().toISOString();
|
|
1451
|
+
if (!(0, risk_gate_1.approvalCovers)(approval, { kind: 'review-resolve', target: f.id }, nowTs)) {
|
|
1452
|
+
throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`);
|
|
1453
|
+
}
|
|
1454
|
+
// @implements A-SPEC-191 §11 — 한 배치가 같은 id 를 다시 열고 다시 닫으면 lift 는 두 번
|
|
1455
|
+
// 일어난다. 소비는 호출당 1회이므로, 세 번의 호출이면 거부됐을 일이 한 배치에서는
|
|
1456
|
+
// 통과했다(실측: 최종 상태와 게이트 판정이 갈렸다).
|
|
1457
|
+
if (liftedCriticals.includes(f.id)) {
|
|
1458
|
+
throw new HandlerRefusal(`review_record: 한 배치에서 같은 발견(${f.id})의 치명 해소를 두 번 들 수 없습니다 — 승인은 행위마다 필요합니다`);
|
|
1459
|
+
}
|
|
1460
|
+
liftedCriticals.push(f.id);
|
|
1461
|
+
}
|
|
1462
|
+
latest.set(f.id, f); // 한 배치 안의 순서도 기록 순서다
|
|
1463
|
+
}
|
|
1464
|
+
// A-SPEC-133 promises nonce = single-use "consumed on the ledger, a replay is denied".
|
|
1465
|
+
// Consumed AFTER the whole batch validates and ONCE per call (round-4: consuming inside
|
|
1466
|
+
// the loop burned the grant when a LATER row failed validation — the audit trail asserted
|
|
1467
|
+
// an act that never happened, the retry was refused, and two resolves in one batch would
|
|
1468
|
+
// have double-spent). Consumption directly precedes the append; the only failure between
|
|
1469
|
+
// them is the append itself, which throws loudly.
|
|
1470
|
+
if (liftedCriticals.length > 0) {
|
|
1471
|
+
const raw = process.env.HOLMES_APPROVAL;
|
|
1472
|
+
let approval;
|
|
1473
|
+
try {
|
|
1474
|
+
approval = raw ? JSON.parse(raw) : undefined;
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
approval = undefined;
|
|
1478
|
+
}
|
|
1479
|
+
if ((0, provenance_chain_1.blankNonce)(approval?.nonce)) {
|
|
1480
|
+
throw new HandlerRefusal('review_record: 승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
|
|
1481
|
+
}
|
|
1482
|
+
// @implements A-SPEC-191 §13 — 1회용 승인은 한 번의 행위를 authorize 한다. 9라운드
|
|
1483
|
+
// 실측: 서로 다른 id 의 치명 해소 N 건이 한 배치에서 nonce 한 장으로 통과했고,
|
|
1484
|
+
// 같은 세 건을 세 호출로 나누면 두 번째부터 거부됐다 — 판정이 묶음 방식에 의존했다.
|
|
1485
|
+
if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce) && liftedCriticals.length > 1) {
|
|
1486
|
+
throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${liftedCriticals.length}건(${liftedCriticals.join(', ')})을 듭니다. 한 건씩 보내십시오`);
|
|
1487
|
+
}
|
|
1488
|
+
if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce)) {
|
|
1489
|
+
// @implements A-SPEC-191 §10 — the same bound anchor as risk_check: two consumers must
|
|
1490
|
+
// spend into ONE ledger, or a nonce spent here reopens the gate there (and back).
|
|
1491
|
+
const ledgerFile = boundNonceLedger(store);
|
|
1492
|
+
if (ledgerFile === null) {
|
|
1493
|
+
throw new HandlerRefusal('review_record: 단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다');
|
|
1494
|
+
}
|
|
1495
|
+
const won = (0, provenance_chain_1.consumeNonceExclusively)(String(approval.nonce), ledgerFile, {
|
|
1496
|
+
ts: new Date().toISOString(), actor: approval.actor, kind: 'nonce-consumed',
|
|
1497
|
+
summary: `consumed single-use approval for: review-resolve ${liftedCriticals.join(', ')}`.slice(0, 200),
|
|
1498
|
+
inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
|
|
1499
|
+
authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
|
|
1500
|
+
});
|
|
1501
|
+
if (!won) {
|
|
1502
|
+
throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 거부됩니다. 새 승인을 발급받으십시오`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
// @implements A-SPEC-157 — the server's OWN observation, not anything the caller sent.
|
|
1507
|
+
// @implements A-SPEC-160 — the ONLY caller that reads the on-disk build, so the divergence
|
|
1508
|
+
// marker can be sealed. Append INSIDE the same lock as the guard (round-6 TOCTOU).
|
|
1509
|
+
ledger.record(a.findings, (0, basis_1.basisDigest)(basisFor(a.root, true)));
|
|
1510
|
+
});
|
|
1511
|
+
return { recorded: a.findings.length };
|
|
1512
|
+
},
|
|
1513
|
+
async review_status(a) {
|
|
1514
|
+
// @implements A-SPEC-191 §15 — the merge gate must not answer about a project it did not read.
|
|
1515
|
+
const foreign = foreignRootReason(store, a.root);
|
|
1516
|
+
if (foreign !== null)
|
|
1517
|
+
throw new HandlerRefusal(`review_status: ${foreign}`);
|
|
1518
|
+
// @implements A-SPEC-191 (§4f) — counted on the LATEST record per id, the same folding the
|
|
1519
|
+
// constitution's ART-7 uses. Line-based counting could never decrease on an append-only
|
|
1520
|
+
// ledger, so the canonical exit (a resolved line) left `blocked: true` forever while the Stop
|
|
1521
|
+
// gate passed — two official verdicts about one ledger, disagreeing permanently (measured).
|
|
1522
|
+
const all = new findings_1.FindingsLedger(boundFindingsLedger(store, a.root)).list();
|
|
1523
|
+
const latest = new Map();
|
|
1524
|
+
// Severity of the LATEST open row per id — a forever-sticky "ever critical" set resurrected
|
|
1525
|
+
// a properly-closed critical when a later minor re-finding got a diverged resolve (r6).
|
|
1526
|
+
const lastOpenCritical = new Map();
|
|
1527
|
+
for (const f of all) {
|
|
1528
|
+
latest.set(f.id, f);
|
|
1529
|
+
if (f.status === 'open')
|
|
1530
|
+
lastOpenCritical.set(f.id, f.severity === 'critical');
|
|
1531
|
+
}
|
|
1532
|
+
// SAME ledger meaning as ART-7 (round-3: the two official readers disagreed): a resolution
|
|
1533
|
+
// sealed on a diverged build does not close a critical here either. What it keeps open is
|
|
1534
|
+
// the CRITICAL it failed to close — bucketing by the resolved line's own severity let a
|
|
1535
|
+
// severity-downgraded diverged resolve report blocked:false while the Stop gate blocked
|
|
1536
|
+
// (round-4 HIGH), and a severity-less row minted an "undefined" bucket.
|
|
1537
|
+
const counts = { critical: 0, important: 0, minor: 0 };
|
|
1538
|
+
for (const f of latest.values()) {
|
|
1539
|
+
if (f.status === 'open') {
|
|
1540
|
+
if (Object.prototype.hasOwnProperty.call(counts, f.severity))
|
|
1541
|
+
counts[f.severity]++; // in-체크는 프로토타입 키(toString)로 새었다(r6)
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
if (f.status === 'resolved' && lastOpenCritical.get(f.id) === true && (0, basis_1.basisDiverged)(f.basis))
|
|
1545
|
+
counts.critical++;
|
|
1546
|
+
}
|
|
1547
|
+
// @implements A-SPEC-135
|
|
1548
|
+
// Surface outstanding P4 routing signals: specs whose approved content moved, with the anchored
|
|
1549
|
+
// files awaiting targeted review. This is a backlog to route human attention, NOT a hard block —
|
|
1550
|
+
// the review layer decides severity — so it does not touch `blocked`.
|
|
1551
|
+
// @implements A-SPEC-189 §12 (round 12) — the findings moved onto the bound project in §8 and
|
|
1552
|
+
// this line stayed on the caller's raw root, so the SAME project answered with or without a
|
|
1553
|
+
// P4 routing backlog depending on how its root was spelled (a subdirectory passes the
|
|
1554
|
+
// project-level foreign check). One question, one tree.
|
|
1555
|
+
const reviewNeeded = (0, review_targets_1.readReviewNeeded)(path.join(path.dirname(boundFindingsLedger(store, a.root)), 'provenance.jsonl'));
|
|
1556
|
+
return { blocked: counts.critical > 0 || counts.important > 0, open: counts, reviewNeeded };
|
|
1557
|
+
},
|
|
1558
|
+
// @implements A-SPEC-125.4
|
|
1559
|
+
async risk_check(a) {
|
|
1560
|
+
const action = { ...a.action };
|
|
1561
|
+
if (a.root && a.changedSymbols && a.changedSymbols.length) {
|
|
1562
|
+
// @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
|
|
1563
|
+
// advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
|
|
1564
|
+
const root = projectRootOf(a.root);
|
|
1565
|
+
const specs = await store.list();
|
|
1566
|
+
const scanned = cachedScan(root, root);
|
|
1567
|
+
const g = new rtm_graph_1.RtmGraph();
|
|
1568
|
+
try {
|
|
1569
|
+
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
1570
|
+
const impacted = (0, rtm_builder_1.impactedBy)(g, a.changedSymbols);
|
|
1571
|
+
action.blastRadius = { impactedSpecCount: impacted.length, hitsFoundational: impacted.some((id) => id.startsWith('SPEC:REQ-')) };
|
|
1572
|
+
}
|
|
1573
|
+
finally {
|
|
1574
|
+
g.close(); // release native SQLite handle even if build/query throws
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
const assessment = (0, risk_classifier_1.assessRisk)(action);
|
|
1578
|
+
// @implements A-SPEC-133 — coverage (scope + expiry), not mere well-formedness, decides whether
|
|
1579
|
+
// this approval authorizes THIS action. An out-of-scope or expired token no longer unblocks.
|
|
1580
|
+
const coverTarget = { kind: action.kind, target: typeof action.target === 'string' ? action.target : String(action.target ?? '') };
|
|
1581
|
+
let gate = (0, risk_gate_1.riskGate)(assessment, a.approval, a.enforcement, { action: coverTarget, now: a.ts });
|
|
1582
|
+
const covers = (0, risk_gate_1.approvalCovers)(a.approval, coverTarget, a.ts);
|
|
1583
|
+
// @implements A-SPEC-133 — the hard-hitl branch HONORS a nonce by SPENDING it (r7-191 HIGH:
|
|
1584
|
+
// this consumer accepted single-use approvals and spent nothing — 'N-RISK' unblocked three
|
|
1585
|
+
// times; a replayed approval must deny exactly as an absent one would).
|
|
1586
|
+
// @implements A-SPEC-191 §13 — 승인이 실제로 문을 여는 경우에만 승인의 하자를 따진다.
|
|
1587
|
+
// 9라운드 실측: 승인이 아예 필요 없던 낮은 판정까지 환경에 놓인 토큰의 모양 때문에
|
|
1588
|
+
// 차단됐다 — 승인을 쥔 쪽이 안 쥔 쪽보다 나빠지는 역전이다.
|
|
1589
|
+
const wouldBlockWithout = (0, risk_gate_1.riskGate)(assessment, undefined, a.enforcement, { action: coverTarget, now: a.ts }).blocked;
|
|
1590
|
+
if (!gate.blocked && covers && wouldBlockWithout && (0, provenance_chain_1.blankNonce)(a.approval?.nonce)) {
|
|
1591
|
+
gate = { ...gate, blocked: true, reasons: [...gate.reasons, '승인이 단일 사용(nonce)을 선언했으나 값이 비어 있거나 문자열이 아닙니다 — 1회성을 집행할 수 없어 거부합니다'] };
|
|
1592
|
+
}
|
|
1593
|
+
// 묶인 프로젝트가 없으면 개방을 기록할 수 없다 — 기록되지 않는 개방은 개방하지 않는다.
|
|
1594
|
+
if (!gate.blocked && covers && wouldBlockWithout && boundNonceLedger(store) === null) {
|
|
1595
|
+
gate = { ...gate, blocked: true, reasons: [...gate.reasons, '개방을 기록할 프로젝트 원장을 확정할 수 없습니다 — 기록 없는 개방은 하지 않습니다(.ax 를 가진 프로젝트에 바인딩된 서버에서 호출하십시오)'] };
|
|
1596
|
+
}
|
|
1597
|
+
if (assessment.level === 'hard-hitl' && !gate.blocked && covers && (0, provenance_chain_1.nonceDeclared)(a.approval?.nonce)) {
|
|
1598
|
+
const ledgerFile = boundNonceLedger(store);
|
|
1599
|
+
let won = false;
|
|
1600
|
+
try {
|
|
1601
|
+
won = ledgerFile !== null && (0, provenance_chain_1.consumeNonceExclusively)(String(a.approval.nonce), ledgerFile, {
|
|
1602
|
+
ts: a.ts, actor: a.approval.actor, kind: 'nonce-consumed',
|
|
1603
|
+
summary: `consumed single-use approval for: ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 200),
|
|
1604
|
+
inputs: [(0, provenance_chain_1.nonceFingerprint)(String(a.approval.nonce))], rationale: a.approval.rationale,
|
|
1605
|
+
authorization: (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token),
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
catch {
|
|
1609
|
+
won = false;
|
|
1610
|
+
} // 배타 확보 실패 = 허용 불가(fail-closed)
|
|
1611
|
+
if (!won) {
|
|
1612
|
+
gate = { ...gate, blocked: true, reasons: [...gate.reasons, ledgerFile === null
|
|
1613
|
+
? '단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다(.ax 를 가진 프로젝트에 바인딩된 서버에서 호출하십시오)'
|
|
1614
|
+
: '단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 부재와 같이 거부됩니다'] };
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
// Honest provenance: a confirm-level action that was NOT blocked but received no COVERING
|
|
1618
|
+
// approval must NOT be recorded as 'approved' (nobody authorized it for this action) — it
|
|
1619
|
+
// 'proceeded unconfirmed'. Only a genuine covering out-of-band approval yields 'approved'.
|
|
1620
|
+
const decision = gate.blocked
|
|
1621
|
+
? 'blocked'
|
|
1622
|
+
: covers
|
|
1623
|
+
? 'approved'
|
|
1624
|
+
: gate.requiresApproval
|
|
1625
|
+
? 'proceeded-unconfirmed'
|
|
1626
|
+
: 'auto';
|
|
1627
|
+
const evt = { ts: a.ts, actor: a.actor ?? a.approval?.actor ?? 'unknown', action, level: assessment.level, decision, rationale: a.rationale ?? a.approval?.rationale ?? '', reasons: assessment.reasons };
|
|
1628
|
+
// @implements A-SPEC-191 §10 — a bound server files its decisions in the project it is bound
|
|
1629
|
+
// to. r8: the audit line for a nonce-replay opening landed in the caller's scratch directory,
|
|
1630
|
+
// so the project whose gate was opened held no trace of it.
|
|
1631
|
+
const bound = boundNonceLedger(store);
|
|
1632
|
+
const decisionsFile = bound !== null
|
|
1633
|
+
? path.join(path.dirname(bound), 'decisions.jsonl')
|
|
1634
|
+
: path.join(a.root ?? '.', '.ax', 'ledger', 'decisions.jsonl');
|
|
1635
|
+
// @implements A-SPEC-191 §11 — an audit write must not swallow a verdict the caller already
|
|
1636
|
+
// earned (the hook has said this since N3). The nonce is spent for an opening that DOES reach
|
|
1637
|
+
// the caller; a disk failure here loses the record, not the decision.
|
|
1638
|
+
try {
|
|
1639
|
+
new decision_ledger_1.DecisionLedger(decisionsFile).record([evt]);
|
|
1640
|
+
}
|
|
1641
|
+
catch { /* 기록 실패가 이미 계산된 판정을 뒤집지 않는다 */ }
|
|
1642
|
+
// @implements A-SPEC-133 — 'Every gate opening records a provenance approved-action entry.'
|
|
1643
|
+
// r8: the promise was kept in the hook and broken here — a covering approval that unblocked a
|
|
1644
|
+
// hard-hitl risk_check left only `nonce-consumed` (and nothing at all when it carried no
|
|
1645
|
+
// nonce), so the master key's use was invisible in the chain the audit reads.
|
|
1646
|
+
const wouldBlock = wouldBlockWithout;
|
|
1647
|
+
const bnl = boundNonceLedger(store);
|
|
1648
|
+
if (!gate.blocked && covers && wouldBlock && bnl !== null) {
|
|
1649
|
+
try {
|
|
1650
|
+
new provenance_chain_1.ProvenanceChain(bnl).append({
|
|
1651
|
+
ts: a.ts, actor: a.approval?.actor ?? 'unknown', kind: 'approved-action',
|
|
1652
|
+
summary: `approved action: risk_check ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 300),
|
|
1653
|
+
inputs: ['risk_check', (0, provenance_chain_1.redactTarget)('command', coverTarget.target), (0, provenance_chain_1.approvalMarkers)(a.approval)],
|
|
1654
|
+
rationale: a.approval?.rationale ?? '',
|
|
1655
|
+
authorization: a.approval ? (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token) : undefined,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
catch { /* 감사 기록 실패가 판정을 바꾸지 않는다 */ }
|
|
1659
|
+
}
|
|
1660
|
+
return { assessment, gate };
|
|
1661
|
+
},
|
|
1662
|
+
// @implements A-SPEC-126
|
|
1663
|
+
// Brownfield reverse engineering. Three explicitly-invoked tools, nothing running as a side
|
|
1664
|
+
// effect of an ordinary session, and every write path opt-in: scan writes nothing at all, draft
|
|
1665
|
+
// and anchor are dry-run by DEFAULT.
|
|
1666
|
+
/**
|
|
1667
|
+
* Read-only inventory of a brownfield target: candidate clusters, coverage, and what the scan
|
|
1668
|
+
* could not resolve. Deliberately does NOT call assertRepoTopLevel — a target that is not a git
|
|
1669
|
+
* repository is a supported case here, reported as `isGit: false`.
|
|
1670
|
+
*/
|
|
1671
|
+
async reverse_scan(a) {
|
|
1672
|
+
// @implements A-SPEC-189 §10 (round 11) — `reverse_scan`/`reverse_draft` never call
|
|
1673
|
+
// `projectRootOf`, so §7's refusal marker never reached them and their own POINTED sentence
|
|
1674
|
+
// ("<root> is not a directory") arrived at the wire as a raw -32603 fault. A refusal about the
|
|
1675
|
+
// caller's own argument is a refusal wherever it is authored.
|
|
1676
|
+
assertReadableRoot('reverse_scan', a.root);
|
|
1677
|
+
// `surfaceByCluster` is drafting evidence, not reading material. On the calibration target it
|
|
1678
|
+
// is 11,460 characters across 15 clusters, and this response enters context on every scan —
|
|
1679
|
+
// paying that to READ what only DRAFTING consumes. `reverse_draft` reads it in-process instead.
|
|
1680
|
+
const { surfaceByCluster: _drafting, ...response } = (0, scan_1.reverseScan)(a.root, { maxFlagged: a.maxFlagged });
|
|
1681
|
+
return response;
|
|
1682
|
+
},
|
|
1683
|
+
/**
|
|
1684
|
+
* Draft H-SPEC/A-SPEC/T-SPEC documents for the recovered clusters under an EXISTING parent REQ.
|
|
1685
|
+
*
|
|
1686
|
+
* The REQ is the human's to write and this tool refuses without one — a requirement states
|
|
1687
|
+
* business intent, which is not in the code. Everything emitted is `status: draft`, and writing
|
|
1688
|
+
* requires an explicit `dryRun: false`.
|
|
1689
|
+
*
|
|
1690
|
+
* WHERE THE DOCUMENTS LAND: in the SERVER's configured spec store (`HOLMES_SPECS`, default
|
|
1691
|
+
* `.ax/specs` relative to the server's working directory) — not inside `root`. In the intended
|
|
1692
|
+
* adoption the two are the same directory, because the server runs inside the target it governs;
|
|
1693
|
+
* pointing `root` at a different repository drafts that repository's slices into THIS store,
|
|
1694
|
+
* which is a governance decision the caller has to make deliberately.
|
|
1695
|
+
*/
|
|
1696
|
+
async reverse_draft(a) {
|
|
1697
|
+
assertReadableRoot('reverse_draft', a.root);
|
|
1698
|
+
if (typeof a.parentReqId !== 'string' || a.parentReqId.trim() === '') {
|
|
1699
|
+
return {
|
|
1700
|
+
ok: false,
|
|
1701
|
+
reason: 'parentReqId is required. A REQ states business intent, which does not exist in the code ' +
|
|
1702
|
+
'and cannot be recovered from it — write the REQ first, then re-run with its id.',
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
const parentReqId = a.parentReqId.trim();
|
|
1706
|
+
const specs = await store.list();
|
|
1707
|
+
const req = specs.find((s) => s.id === parentReqId);
|
|
1708
|
+
// An unresolvable parent would emit an orphan H-SPEC: a chain that only looks complete.
|
|
1709
|
+
if (!req)
|
|
1710
|
+
return { ok: false, reason: `parent REQ ${parentReqId} was not found in the spec store — create it first` };
|
|
1711
|
+
// @implements A-SPEC-184
|
|
1712
|
+
// A document with no `type:` is not a wrong-kind parent — it is an older-format one, and saying
|
|
1713
|
+
// "is a undefined" left the adopter holding a visible document with nowhere to go. When the type
|
|
1714
|
+
// IS declared, `legacyMessage` returns null and the original wording stands: that path was
|
|
1715
|
+
// already accurate and REQ-184 does not touch it.
|
|
1716
|
+
const formatWhy = (0, legacy_format_1.legacyMessage)(req);
|
|
1717
|
+
if (formatWhy)
|
|
1718
|
+
return { ok: false, reason: formatWhy };
|
|
1719
|
+
if (req.type !== 'REQ')
|
|
1720
|
+
return { ok: false, reason: `${parentReqId} is a ${req.type}; drafts must hang off a REQ` };
|
|
1721
|
+
const report = (0, scan_1.reverseScan)(a.root);
|
|
1722
|
+
// A cluster already drafted is recognised by the `reverse_cluster` key its documents carry, so
|
|
1723
|
+
// re-running over the same tree never duplicates a slice.
|
|
1724
|
+
const alreadyDrafted = new Set(specs.map((s) => s.frontmatter.reverse_cluster).filter((k) => typeof k === 'string'));
|
|
1725
|
+
const selected = a.cluster ? report.clusters.filter((c) => (0, draft_1.clusterKeyOf)(c) === a.cluster) : report.clusters;
|
|
1726
|
+
// A key that matches nothing must not read as success: "drafted 0" is indistinguishable from
|
|
1727
|
+
// "everything was already drafted", so a mistyped key would silently skip the work.
|
|
1728
|
+
if (a.cluster && selected.length === 0) {
|
|
1729
|
+
const keys = report.clusters.map(draft_1.clusterKeyOf);
|
|
1730
|
+
return {
|
|
1731
|
+
ok: false,
|
|
1732
|
+
reason: `no cluster matches key "${a.cluster}" — reverse_scan reports ${keys.length} cluster(s)`,
|
|
1733
|
+
availableClusters: keys.slice(0, 50),
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
let base = (0, draft_1.nextIdBase)(specs.map((s) => s.id));
|
|
1737
|
+
const drafted = [];
|
|
1738
|
+
const skipped = [];
|
|
1739
|
+
const refused = [];
|
|
1740
|
+
for (const cluster of selected) {
|
|
1741
|
+
const key = (0, draft_1.clusterKeyOf)(cluster);
|
|
1742
|
+
if (alreadyDrafted.has(key)) {
|
|
1743
|
+
skipped.push({ clusterKey: key, reason: 'already drafted' });
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
const d = (0, draft_1.draftSpecs)(cluster, req.id, String(base), {
|
|
1747
|
+
surface: report.surfaceByCluster[key] ?? [],
|
|
1748
|
+
testFiles: cluster.testFiles ?? [],
|
|
1749
|
+
testsUnmatched: report.coverage.testsUnmatched,
|
|
1750
|
+
});
|
|
1751
|
+
if (!d.ok) {
|
|
1752
|
+
refused.push({ clusterKey: key, reason: d.reason });
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
// Validate BEFORE writing: a draft that fails the project's own validator is not evidence of
|
|
1756
|
+
// anything, and emitting one would put a broken document into the chain.
|
|
1757
|
+
const known = [...specs, ...drafted.flatMap((x) => x.specs), ...d.specs];
|
|
1758
|
+
const resolve = (id) => known.find((s) => s.id === id) ?? null;
|
|
1759
|
+
const errors = d.specs.flatMap((s) => (0, validator_1.validateSpec)(s, resolve).findings.filter((f) => f.level === 'error').map((f) => ({ spec: s.id, ...f })));
|
|
1760
|
+
if (errors.length) {
|
|
1761
|
+
refused.push({ clusterKey: key, reason: 'drafted documents failed validation', findings: errors });
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
drafted.push(d);
|
|
1765
|
+
base++;
|
|
1766
|
+
}
|
|
1767
|
+
const dryRun = a.dryRun !== false; // opt IN to writing
|
|
1768
|
+
// @implements A-SPEC-188 — the minted id sits above every PARSEABLE id, but a file list()
|
|
1769
|
+
// cannot read is invisible to nextIdBase, so its path can collide with a fresh draft. Same
|
|
1770
|
+
// rule as spec_create: a path already occupied is a human's to look at, never overwritten.
|
|
1771
|
+
// The whole cluster moves to `refused` — a partially-written chain only looks complete.
|
|
1772
|
+
// Judged in the DRY RUN too (round-3): a preview that lists a cluster as drafted which the
|
|
1773
|
+
// real run would refuse is a preview that lies.
|
|
1774
|
+
const written = [];
|
|
1775
|
+
for (const d of drafted) {
|
|
1776
|
+
const clash = store instanceof spec_store_1.LocalMarkdownRepository
|
|
1777
|
+
? d.specs.map((s) => store.targetPathFor(s)).find((p) => fs.existsSync(p))
|
|
1778
|
+
: undefined;
|
|
1779
|
+
if (clash) {
|
|
1780
|
+
refused.push({ clusterKey: d.clusterKey, reason: `초안 목적지(${clash})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다. 사람이 확인해 옮기거나 고친 뒤 다시 실행하십시오.` });
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
if (!dryRun) {
|
|
1784
|
+
try {
|
|
1785
|
+
for (const s of d.specs)
|
|
1786
|
+
await store.write(s);
|
|
1787
|
+
}
|
|
1788
|
+
catch (e) {
|
|
1789
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
1790
|
+
refused.push({ clusterKey: d.clusterKey, reason: `초안 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다. 사람이 확인해 옮기거나 고친 뒤 다시 실행하십시오.` });
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
throw e;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
written.push(d);
|
|
1797
|
+
}
|
|
1798
|
+
return { ok: true, dryRun, parentReqId: req.id, clusters: report.clusters.length, drafted: written, skipped, refused };
|
|
1799
|
+
},
|
|
1800
|
+
/**
|
|
1801
|
+
* Insert `@implements` anchors into source files. Dry-run by DEFAULT, and anchoring to a
|
|
1802
|
+
* non-approved A-SPEC is refused — a reverse-engineered draft describes code; description does
|
|
1803
|
+
* not confer approval.
|
|
1804
|
+
*/
|
|
1805
|
+
async reverse_anchor(a) {
|
|
1806
|
+
const specs = await store.list();
|
|
1807
|
+
const approved = specs.filter((s) => s.type === 'A-SPEC' && s.status === 'approved').map((s) => s.id);
|
|
1808
|
+
const plan = (0, anchor_1.planAnchors)(a.root, a.mapping ?? [], approved);
|
|
1809
|
+
// @implements A-SPEC-182
|
|
1810
|
+
// The third place the harness refuses over an unapproved A-SPEC. Enriched HERE rather than
|
|
1811
|
+
// inside planAnchors, which is a pure planner holding only the approved-id list — this is the
|
|
1812
|
+
// boundary that has the spec objects, so the planner stays testable without them.
|
|
1813
|
+
const byId = new Map(specs.map((s) => [s.id, s]));
|
|
1814
|
+
// ONE payload per distinct A-SPEC, on a sibling field — not appended to every refused entry.
|
|
1815
|
+
// Review measured the first attempt, which memoized only the COMPUTATION: 149 files mapped to
|
|
1816
|
+
// one draft A-SPEC still concatenated the same 361-character sentence 149 times, 48 KB of pure
|
|
1817
|
+
// duplication in a single tool result. The comment claimed a property the code did not have,
|
|
1818
|
+
// which is how it survived a round of review — hence the size assertion in the test.
|
|
1819
|
+
const blockers = {};
|
|
1820
|
+
for (const r of plan.refused) {
|
|
1821
|
+
if (!/is not approved$/.test(r.reason) || blockers[r.aspec] !== undefined)
|
|
1822
|
+
continue;
|
|
1823
|
+
const why = (0, approval_blockers_1.blockerSummary)(byId.get(r.aspec), (id) => byId.get(id) ?? null, r.aspec);
|
|
1824
|
+
if (why)
|
|
1825
|
+
blockers[r.aspec] = why.trim();
|
|
1826
|
+
}
|
|
1827
|
+
const applied = (0, anchor_1.applyAnchors)(a.root, plan.edits, { dryRun: a.dryRun !== false });
|
|
1828
|
+
return { ...plan, ...applied, blockers };
|
|
1829
|
+
},
|
|
1830
|
+
};
|
|
1831
|
+
}
|