@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,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NEAR_CACHE_SIZE = void 0;
|
|
4
|
+
exports.nearKeys = nearKeys;
|
|
5
|
+
exports.nearestKey = nearestKey;
|
|
6
|
+
exports.validateArgs = validateArgs;
|
|
7
|
+
const typeOf = (v) => Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v;
|
|
8
|
+
/**
|
|
9
|
+
* Nearest declared key by edit distance, or null.
|
|
10
|
+
*
|
|
11
|
+
* Threshold 2: `aspecId`→`aspec` (distance 2) and `mappings`→`mapping` (distance 1) are the
|
|
12
|
+
* measured mistakes this exists for. Anything farther is guessing, and a wrong suggestion is worse
|
|
13
|
+
* than none. Ties resolve to the first declared key, which keeps the output deterministic.
|
|
14
|
+
*/
|
|
15
|
+
/** Suggestion work is BOUNDED: keys longer than this are never candidates (adversarial 50k-char
|
|
16
|
+
* keys measured seconds of Levenshtein on the single-threaded server), and a length gap beyond the
|
|
17
|
+
* threshold cannot be within distance 2, so it is pruned before any table is built. */
|
|
18
|
+
const KEY_MAX = 64;
|
|
19
|
+
// Damerau (round-10): an adjacent transposition is ONE slip of the fingers, and counting it as two
|
|
20
|
+
// edits is why `actro` needed a distance-2 budget to reach `actor` — the same budget that made
|
|
21
|
+
// `rule`/`line`/`port` "typos" of `file`/`root`.
|
|
22
|
+
const dist = (a, b) => {
|
|
23
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
24
|
+
for (let j = 0; j <= b.length; j++)
|
|
25
|
+
dp[0][j] = j;
|
|
26
|
+
for (let i = 1; i <= a.length; i++) {
|
|
27
|
+
for (let j = 1; j <= b.length; j++) {
|
|
28
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
29
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
30
|
+
dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return dp[a.length][b.length];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* ALL declared keys within edit distance 1..2 of `unknown`, nearest first (ties keep declared
|
|
38
|
+
* order). The single-best shape let a SUPPLIED sibling shadow an absent key's typo into a silent
|
|
39
|
+
* pass — round-5 measured: risk_check's `actro` tied to the supplied `action` (declared first), so
|
|
40
|
+
* the absent `actor` never entered the judgment and the ledger lost its attribution.
|
|
41
|
+
*/
|
|
42
|
+
function nearKeysWithDist(unknown, declared) {
|
|
43
|
+
if (unknown.length > KEY_MAX)
|
|
44
|
+
return [];
|
|
45
|
+
const hits = [];
|
|
46
|
+
for (const d of declared) {
|
|
47
|
+
if (d.length > KEY_MAX || Math.abs(d.length - unknown.length) >= 3)
|
|
48
|
+
continue;
|
|
49
|
+
const dd = dist(unknown, d);
|
|
50
|
+
// @implements A-SPEC-189 §7 (round 10) — the tolerance scales with the SHORTER name. A flat
|
|
51
|
+
// distance of 2 is most of a four-letter word: measured, advertising `file`/`specRef` on a
|
|
52
|
+
// finding turned `line`, `rule`, `fix`, `title` into "typos" and refused perfectly ordinary
|
|
53
|
+
// findings, and `port`/`host`/`cost`/`sort`/`tool` became typos of spec_create's `root`,
|
|
54
|
+
// breaking its caller-wins frontmatter contract. Two edits away from a short word is usually a
|
|
55
|
+
// DIFFERENT word — except when one name merely extends the other (`te`→`test`,
|
|
56
|
+
// `aspecId`→`aspec`), which is a slip of scope, not a different word.
|
|
57
|
+
const extendsOther = unknown.startsWith(d) || d.startsWith(unknown);
|
|
58
|
+
const budget = extendsOther || Math.min(unknown.length, d.length) > 5 ? 2 : 1;
|
|
59
|
+
if (dd > 0 && dd <= budget)
|
|
60
|
+
hits.push({ d, dd });
|
|
61
|
+
}
|
|
62
|
+
return hits.sort((a, b) => a.dd - b.dd);
|
|
63
|
+
}
|
|
64
|
+
function nearKeys(unknown, declared) {
|
|
65
|
+
return nearKeysWithDist(unknown, declared).map((h) => h.d);
|
|
66
|
+
}
|
|
67
|
+
function nearestKey(unknown, declared) {
|
|
68
|
+
return nearKeys(unknown, declared)[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
/** Cap on rendered violation items — the reason enters context on every refusal (REQ-183 rule). */
|
|
71
|
+
const ITEM_CAP = 8;
|
|
72
|
+
// @implements A-SPEC-189 §6 — `nearKeysWithDist` is a pure function of (unknown key, declared
|
|
73
|
+
// keys), and a large payload repeats the SAME unknown key across thousands of array elements
|
|
74
|
+
// (round-9 measured 262k keys, one request holding the single-threaded server for seconds). A
|
|
75
|
+
// memo keyed by both operands removes that repetition WITHOUT bounding anything: no verdict
|
|
76
|
+
// depends on how many keys came before, which is the property round-4 and round-5 were withdrawn
|
|
77
|
+
// to protect.
|
|
78
|
+
const NEAR_CACHE = new Map();
|
|
79
|
+
/** 시험용 관측점: 요청이 끝난 뒤 메모가 비어 있음을 고정한다(§7f). */
|
|
80
|
+
const NEAR_CACHE_SIZE = () => NEAR_CACHE.size;
|
|
81
|
+
exports.NEAR_CACHE_SIZE = NEAR_CACHE_SIZE;
|
|
82
|
+
function nearCache(u, declared) {
|
|
83
|
+
// Round-10: a key over KEY_MAX computes nothing (nearKeysWithDist returns [] immediately), so
|
|
84
|
+
// caching it bought zero and held the attacker's bytes — measured 38.6 MB still resident after
|
|
85
|
+
// the request ended. Nothing to compute, nothing to remember.
|
|
86
|
+
if (u.length > KEY_MAX)
|
|
87
|
+
return [];
|
|
88
|
+
const key = `${u}\u0000${declared.join('\u0001')}`;
|
|
89
|
+
const hit = NEAR_CACHE.get(key);
|
|
90
|
+
if (hit)
|
|
91
|
+
return hit;
|
|
92
|
+
const computed = nearKeysWithDist(u, declared);
|
|
93
|
+
if (NEAR_CACHE.size > 4096)
|
|
94
|
+
NEAR_CACHE.clear(); // 메모는 캐시지 원장이 아니다
|
|
95
|
+
NEAR_CACHE.set(key, computed);
|
|
96
|
+
return computed;
|
|
97
|
+
}
|
|
98
|
+
function judgeObject(path, schema, value, out) {
|
|
99
|
+
const props = schema.properties ?? {};
|
|
100
|
+
const declared = Object.keys(props);
|
|
101
|
+
const present = Object.keys(value);
|
|
102
|
+
// NO scan cap (round-5 HIGH): slicing unknown keys made the typo REFUSAL insertion-order
|
|
103
|
+
// dependent — 32 pad keys before `dryrun` flipped the same call from refusal to silent pass.
|
|
104
|
+
// Judgment must not depend on where a key sits. Cost stays bounded without it: KEY_MAX excludes
|
|
105
|
+
// long keys, the length-gap prune skips dissimilar pairs, and declared-key lists are short.
|
|
106
|
+
const unknownKeys = present.filter((k) => !declared.includes(k));
|
|
107
|
+
// additionalProperties: true is an EXPLICIT contract that extra keys are data (spec_create's
|
|
108
|
+
// caller-wins frontmatter) — no typo judgement may fire there.
|
|
109
|
+
const extrasAreData = schema.additionalProperties === true;
|
|
110
|
+
const required = schema.required ?? [];
|
|
111
|
+
// ASSIGNMENT, not a target-first map (round-8): every earlier shape let one unknown key render
|
|
112
|
+
// on several lines (bark → mark AND base), let insertion order drop a second typo's line
|
|
113
|
+
// outright (ro preempted root, roo vanished), and let a d2 first-comer swallow a d1 typo's
|
|
114
|
+
// target. The contract is per UNKNOWN key: at most ONE line, aimed at its NEAREST ABSENT
|
|
115
|
+
// declared key; if its nearest candidates are all SUPPLIED, the extra is data (§5c). Ties
|
|
116
|
+
// prefer the absent candidate, then declared order — deterministic and order-independent.
|
|
117
|
+
//
|
|
118
|
+
// NO global budget (round-4) and NO per-object scan cap (round-5): both made a REFUSAL depend
|
|
119
|
+
// on where the key sat. The surviving DoS bounds are KEY_MAX and the length-gap prune;
|
|
120
|
+
// declared-key lists are short, so cost is linear in input size with a small constant.
|
|
121
|
+
const isAbsent = (k) => value[k] === undefined || value[k] === null;
|
|
122
|
+
const assign = new Map(); // unknown key -> its one absent target
|
|
123
|
+
const assignDist = new Map();
|
|
124
|
+
for (const u of unknownKeys) {
|
|
125
|
+
const cands = nearCache(u, declared);
|
|
126
|
+
if (cands.length === 0)
|
|
127
|
+
continue;
|
|
128
|
+
const minD = cands[0].dd;
|
|
129
|
+
const absentAtMin = cands.filter((c) => c.dd === minD).find((c) => isAbsent(c.d));
|
|
130
|
+
if (absentAtMin) {
|
|
131
|
+
assign.set(u, absentAtMin.d);
|
|
132
|
+
assignDist.set(u, minD);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// @implements A-SPEC-189 §7 (round 10) — §5c says an extra beside a SUPPLIED near-name is data,
|
|
136
|
+
// and that stays true where extras are data by contract. But a key ONE edit from a declared
|
|
137
|
+
// CONTROL key, in a schema whose extras become a stored document, is the measured harm itself:
|
|
138
|
+
// `rot: /Users/<name>/…` reached a git-shared frontmatter while `root` was supplied too.
|
|
139
|
+
if (extrasAreData && minD === 1) {
|
|
140
|
+
assign.set(u, cands[0].d);
|
|
141
|
+
assignDist.set(u, minD);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// @implements A-SPEC-189 §6 — every unknown key is NAMED exactly once, and which name comes
|
|
145
|
+
// first is decided by distance then alphabet, never by where the key sat in the object. Round-9
|
|
146
|
+
// measured the old shape: `find()` over insertion order cited a distance-2 typo while the
|
|
147
|
+
// distance-1 typo aimed at the same missing key evaporated from the message entirely, so the same
|
|
148
|
+
// key set refused with different text depending on the caller's key order.
|
|
149
|
+
const spokes = new Map(); // absent target -> its typos, deterministic order
|
|
150
|
+
for (const [u, target] of assign) {
|
|
151
|
+
const list = spokes.get(target);
|
|
152
|
+
if (list)
|
|
153
|
+
list.push(u);
|
|
154
|
+
else
|
|
155
|
+
spokes.set(target, [u]);
|
|
156
|
+
}
|
|
157
|
+
for (const list of spokes.values()) {
|
|
158
|
+
list.sort((x, y) => (assignDist.get(x) - assignDist.get(y)) || (x < y ? -1 : x > y ? 1 : 0));
|
|
159
|
+
}
|
|
160
|
+
// Names are quoted whole with a counted omission label — a cut list reads as a complete one.
|
|
161
|
+
const NAME_BUDGET = 3;
|
|
162
|
+
const quoteAll = (prefix, us) => {
|
|
163
|
+
const kept = us.slice(0, NAME_BUDGET).map((u) => `${prefix}${u}`);
|
|
164
|
+
const omitted = us.length - kept.length;
|
|
165
|
+
return kept.join(', ') + (omitted > 0 ? ` 외 ${omitted}건` : '');
|
|
166
|
+
};
|
|
167
|
+
// NOTE: no `rendered` bookkeeping — each unknown key holds exactly ONE assigned target, so it
|
|
168
|
+
// can appear in the required loop XOR the optional loop, never both (round-8 review would flag
|
|
169
|
+
// a defensive duplicate guard as an equivalent mutant, and it would be right).
|
|
170
|
+
for (const req of required) {
|
|
171
|
+
const v = value[req];
|
|
172
|
+
// null/undefined are ABSENCE, not a type violation: what the caller must do is supply the
|
|
173
|
+
// value, and the sentence should say that rather than argue about null's type.
|
|
174
|
+
if (v === undefined || v === null) {
|
|
175
|
+
const desc = props[req]?.description;
|
|
176
|
+
const us = spokes.get(req) ?? [];
|
|
177
|
+
out.push({
|
|
178
|
+
path: path ? `${path}.${req}` : req,
|
|
179
|
+
message: `이(가) 없습니다${desc ? ` — "${desc}"` : ''}`,
|
|
180
|
+
suggestion: us.length
|
|
181
|
+
? `${quoteAll(path ? `${path}.` : '', us)}는 알 수 없는 키 — ${req}을(를) 잘못 쓴 것 아닙니까`
|
|
182
|
+
: undefined,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// @implements A-SPEC-189 (round 2) — the success-shaped no-op survives through OPTIONAL keys
|
|
187
|
+
// too: `dryrun: false` silently leaves dryRun's default true and the caller believes a write
|
|
188
|
+
// happened. Each unknown key renders through its ASSIGNED target exactly once; a typo whose
|
|
189
|
+
// target is a missing required key folds into that violation's suggestion above (one mistake,
|
|
190
|
+
// one line) — a second typo of the same missing key stays silent rather than duplicating the
|
|
191
|
+
// same fix instruction.
|
|
192
|
+
// @implements A-SPEC-189 §6 — `additionalProperties: true` says extra keys are DATA, and that
|
|
193
|
+
// stays true for names unlike anything declared. It cannot also mean 'a misspelling of a declared
|
|
194
|
+
// CONTROL key is data': round-9 measured `rot: /Users/<name>/…` on spec_create being written into
|
|
195
|
+
// a git-shared document as frontmatter — a machine-local absolute path, no refusal, no trace. A
|
|
196
|
+
// near-miss of a DECLARED key is refused everywhere; only unrelated names travel as data.
|
|
197
|
+
// Round-10: the round-9 determinism reached only the missing-REQUIRED branch. This loop still
|
|
198
|
+
// walked `unknownKeys` in the caller's insertion order and pushed one line per typo, so the same
|
|
199
|
+
// key set rendered differently per permutation (measured across 13 tools), four typos of one
|
|
200
|
+
// target repeated the identical fix instruction four times, and — worse — which typo survived the
|
|
201
|
+
// ITEM_CAP truncation was decided by key order. One line per TARGET, targets in declared order,
|
|
202
|
+
// names ordered by distance then alphabet: the same contract the required branch already keeps.
|
|
203
|
+
for (const target of declared) {
|
|
204
|
+
const us = spokes.get(target);
|
|
205
|
+
if (!us || us.length === 0)
|
|
206
|
+
continue;
|
|
207
|
+
// The missing-required line already carries this fix — but ONLY when the required key is
|
|
208
|
+
// actually missing. Round-11 measured the orphan: on spec_create (extras become a stored
|
|
209
|
+
// document) a one-edit typo of a SUPPLIED required key — `titl`, `typ`, `idd` — was assigned
|
|
210
|
+
// here, rendered nowhere, and written verbatim into a git-shared frontmatter. Same harm as
|
|
211
|
+
// round-9's `rot`, one key over.
|
|
212
|
+
if (required.includes(target) && (value[target] === undefined || value[target] === null))
|
|
213
|
+
continue;
|
|
214
|
+
out.push({
|
|
215
|
+
path: path ? `${path}.${us[0]}` : us[0],
|
|
216
|
+
message: us.length === 1
|
|
217
|
+
? `은(는) 알 수 없는 키입니다 — ${target}을(를) 잘못 쓴 것 아닙니까`
|
|
218
|
+
: `외 ${quoteAll('', us.slice(1))}은(는) 알 수 없는 키입니다 — ${target}을(를) 잘못 쓴 것 아닙니까`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
222
|
+
const v = value[key];
|
|
223
|
+
if (v === undefined || v === null)
|
|
224
|
+
continue; // absence is required's business
|
|
225
|
+
const p = path ? `${path}.${key}` : key;
|
|
226
|
+
if (prop.type && typeOf(v) !== prop.type) {
|
|
227
|
+
out.push({ path: p, message: `의 타입이 선언(${prop.type})과 다릅니다 — 받은 것: ${typeOf(v)}` });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (prop.enum && !prop.enum.includes(v)) {
|
|
231
|
+
out.push({ path: p, message: `이(가) 허용 목록 밖입니다 — 허용: ${prop.enum.join(' | ')}` });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (prop.type === 'array' && Array.isArray(v) && prop.items) {
|
|
235
|
+
const itemSchema = prop.items;
|
|
236
|
+
const structured = Boolean(itemSchema.properties || itemSchema.required);
|
|
237
|
+
v.forEach((item, i) => {
|
|
238
|
+
if (structured && typeOf(item) === 'object') {
|
|
239
|
+
judgeObject(`${p}[${i}]`, itemSchema, item, out);
|
|
240
|
+
}
|
|
241
|
+
else if (itemSchema.type && typeOf(item) !== itemSchema.type) {
|
|
242
|
+
// Scalar item schemas (strArray) were the round-2 hole: declared items:{type:'string'}
|
|
243
|
+
// let numeric elements through to handlers that then failed as success-shaped garbage.
|
|
244
|
+
out.push({ path: `${p}[${i}]`, message: `의 타입이 선언(${itemSchema.type})과 다릅니다 — 받은 것: ${typeOf(item)}` });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (prop.type === 'object' && typeOf(v) === 'object' && (prop.properties || prop.required)) {
|
|
249
|
+
judgeObject(p, prop, v, out);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function validateArgs(toolName, schema, args) {
|
|
254
|
+
// Defense-in-depth for direct/in-process misuse. Over the wire the MCP SDK's request schema
|
|
255
|
+
// already rejects non-object `arguments` at the JSON-RPC layer, so this line is unreachable from
|
|
256
|
+
// a stdio client (round-3 [13]) — it guards the exported function's own contract, not the wire.
|
|
257
|
+
if (typeOf(args) !== 'object') {
|
|
258
|
+
return { ok: false, reason: `${toolName}의 인자는 객체여야 합니다 — 받은 것: ${typeOf(args)}` };
|
|
259
|
+
}
|
|
260
|
+
const out = [];
|
|
261
|
+
try {
|
|
262
|
+
judgeObject('', schema, args, out);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
// The memo is a within-request optimisation: a large payload repeats the same unknown key
|
|
266
|
+
// thousands of times. It is NOT a session cache, and holding a caller's key bytes after the
|
|
267
|
+
// answer is sent is storage nobody asked for (round-10).
|
|
268
|
+
NEAR_CACHE.clear();
|
|
269
|
+
}
|
|
270
|
+
if (out.length === 0)
|
|
271
|
+
return { ok: true };
|
|
272
|
+
// All violations in ONE refusal (no round trips), bounded like every other rendered list here:
|
|
273
|
+
// whole items or none, and past the cap a count instead of a tail.
|
|
274
|
+
const shown = out.slice(0, ITEM_CAP);
|
|
275
|
+
const lines = shown.map((v) => `${v.path}${v.message}${v.suggestion ? ` (${v.suggestion})` : ''}`);
|
|
276
|
+
const more = out.length > shown.length ? ` 외 ${out.length - shown.length}건.` : '';
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: `${toolName} 호출이 선언된 스키마와 다릅니다: ${lines.join('; ')}${more}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-195
|
|
3
|
+
/**
|
|
4
|
+
* Holmes-Kit Centralized Message Registry
|
|
5
|
+
*
|
|
6
|
+
* Single Source of Truth for all user/agent-facing refusal, validation, and diagnostic messages.
|
|
7
|
+
* Unified 3-Part English Format: [Subsystem] Title — Technical Cause — Next Action
|
|
8
|
+
* Internal Holmes-Kit Spec IDs are strictly masked unless HOLMES_DEBUG=1 is set.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MESSAGES = void 0;
|
|
12
|
+
function formatDebugTrace(internalSpecTrace) {
|
|
13
|
+
if (process.env.HOLMES_DEBUG === '1' && internalSpecTrace) {
|
|
14
|
+
return `\n [trace: ${internalSpecTrace}]`;
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
function truncateTitle(title, maxLen = 30) {
|
|
19
|
+
if (!title)
|
|
20
|
+
return '';
|
|
21
|
+
return title.length > maxLen ? `${title.slice(0, maxLen)}...` : title;
|
|
22
|
+
}
|
|
23
|
+
function toTspecId(aspecId) {
|
|
24
|
+
const numMatch = aspecId.match(/\d+/);
|
|
25
|
+
return numMatch ? `T-SPEC-${numMatch[0]}` : `T-SPEC-${aspecId}`;
|
|
26
|
+
}
|
|
27
|
+
exports.MESSAGES = {
|
|
28
|
+
SECURITY_ENV_ACCESS: (internalTrace) => `[Holmes-Kit Security] Direct Access to Process Environment Restricted — Subshell command execution contains environment variable access patterns — Next Action: Security secrets (HOLMES_APPROVAL) are scrubbed from subshell environments. Inject mock environment objects within test runner scope if needed.${formatDebugTrace(internalTrace)}`,
|
|
29
|
+
INTEGRITY_CONFIG_EDIT: (file = '.mcp.json', internalTrace) => `[Holmes-Kit Integrity] Direct Modification of '${file}' Restricted — Harness hook and server configuration is protected against unauthenticated edits — Next Action: Request out-of-band operator configuration or approval.${formatDebugTrace(internalTrace)}`,
|
|
30
|
+
GOVERNANCE_LEDGER_DESTRUCTION: (path = '.ax/', internalTrace) => `[Holmes-Kit Audit] Governance Path Destruction Restricted — Removal command targets protected governance ledger directory '${path}' — Next Action: Governance state cannot be deleted directly. Use 'spec_approve' or governance tools.${formatDebugTrace(internalTrace)}`,
|
|
31
|
+
SEAL_SELF_APPROVAL_RESTRICTED: (specId, internalTrace) => `[Holmes-Kit Seal] Specification Self-Approval Restricted — Creating or promoting '${specId}' to status 'approved' requires out-of-band operator approval (cannot self-approve) — Next Action: Author specification as 'draft', then approve out-of-band via 'spec_approve ${specId}'.${formatDebugTrace(internalTrace)}`,
|
|
32
|
+
SEAL_APPROVED_SPEC_MUTATION: (specId, specTitle, internalTrace) => {
|
|
33
|
+
const boundSpec = specTitle ? `'${specId}' (${truncateTitle(specTitle)})` : `'${specId}'`;
|
|
34
|
+
return `[Holmes-Kit Seal] Approved Specification Modification Restricted — Specification ${boundSpec} is cryptographically sealed (status: approved) — Next Action: Update specification status to 'draft' before editing, or re-approve via 'spec_approve ${specId}' after changes.${formatDebugTrace(internalTrace)}`;
|
|
35
|
+
},
|
|
36
|
+
GOVERNANCE_UNAPPROVED_TSPEC: (tspecId, aspecId, aspecTitle, internalTrace) => {
|
|
37
|
+
const boundSpec = aspecTitle ? `'${aspecId}' (${truncateTitle(aspecTitle)})` : `'${aspecId}'`;
|
|
38
|
+
return `[Holmes-Kit Governance] Linked Test Specification Not Approved — Test specification '${tspecId}' linked to ${boundSpec} is not approved — Next Action: Approve the test specification via 'spec_approve ${tspecId}'.${formatDebugTrace(internalTrace)}`;
|
|
39
|
+
},
|
|
40
|
+
GOVERNANCE_MISSING_TSPEC: (aspecId, aspecTitle, internalTrace) => {
|
|
41
|
+
const boundSpec = aspecTitle ? `'${aspecId}' (${truncateTitle(aspecTitle)})` : `'${aspecId}'`;
|
|
42
|
+
const targetTspec = toTspecId(aspecId);
|
|
43
|
+
return `[Holmes-Kit Governance] Missing Test Specification — Specification ${boundSpec} lacks an approved linked test specification — Next Action: Create a mirroring T-SPEC and approve it via 'spec_approve ${targetTspec}'.${formatDebugTrace(internalTrace)}`;
|
|
44
|
+
},
|
|
45
|
+
VALIDATION_MISSING_REQUIRED: (paramPath, description) => `validate-args: parameter '${paramPath}' is required${description ? ` — "${description}"` : ''}`,
|
|
46
|
+
VALIDATION_TYPO_SUGGESTION: (unknownKey, suggestedKey) => `validate-args: '${unknownKey}' is an unknown property — did you mean '${suggestedKey}'?`,
|
|
47
|
+
VALIDATION_ENUM_INVALID: (paramName, value, allowedValues) => `validate-args: value '${value}' for '${paramName}' is invalid — allowed values: [${allowedValues.map((v) => `'${v}'`).join(', ')}]`,
|
|
48
|
+
VALIDATION_FOREIGN_ROOT: (root, boundRoot) => `foreign-root: specified root '${root}' is outside the bound project root '${boundRoot}'`,
|
|
49
|
+
LEGACY_REVERSE_DRAFT_PARENT: () => `parentReqId is required. A REQ states business intent... write the REQ first, then re-run with its id`,
|
|
50
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
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.BASELINE_DIR = void 0;
|
|
37
|
+
exports.baselineDigest = baselineDigest;
|
|
38
|
+
exports.writeBaseline = writeBaseline;
|
|
39
|
+
exports.isBaselineTrusted = isBaselineTrusted;
|
|
40
|
+
exports.readBaseline = readBaseline;
|
|
41
|
+
exports.shouldRecordBaseline = shouldRecordBaseline;
|
|
42
|
+
exports.listBaselines = listBaselines;
|
|
43
|
+
// @implements A-SPEC-128
|
|
44
|
+
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const path = __importStar(require("node:path"));
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const ledger_store_1 = require("../governance/ledger-store");
|
|
48
|
+
/**
|
|
49
|
+
* A NAMED reference point: "the tree as it was when this label was taken".
|
|
50
|
+
*
|
|
51
|
+
* This is the piece that makes change analysis possible without version control, and it adds no new
|
|
52
|
+
* machinery — `HashCache` already persists sha256-per-file and `ScanFileCache` stores the same hash
|
|
53
|
+
* beside each file's symbols. What was missing is a NAME, so a caller can ask "what changed since the
|
|
54
|
+
* last verified state" instead of "what changed since HEAD~1".
|
|
55
|
+
*
|
|
56
|
+
* That naming is also why this is an improvement INSIDE a git project. `HEAD~1` is a proxy for "what I
|
|
57
|
+
* have been working on" that depends entirely on commit habits — ten commits or none change the
|
|
58
|
+
* answer. `last-green`, written only when a run actually passed, does not.
|
|
59
|
+
*
|
|
60
|
+
* Storage is derived state under `.ax/state/`: reconstructable, machine-local, gitignored. It is not
|
|
61
|
+
* project content and must never be committed.
|
|
62
|
+
*/
|
|
63
|
+
exports.BASELINE_DIR = path.join('.ax', 'state', 'baselines');
|
|
64
|
+
/**
|
|
65
|
+
* Labels arrive from tool input and become a filename, so a traversing label would write a file
|
|
66
|
+
* anywhere on the host. Restricted to one safe path segment; anything else is refused loudly.
|
|
67
|
+
*/
|
|
68
|
+
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
69
|
+
function assertLabel(label) {
|
|
70
|
+
if (typeof label !== 'string' || !LABEL_RE.test(label) || label === '.' || label === '..') {
|
|
71
|
+
throw new Error(`baseline: invalid label ${JSON.stringify(label)} — use letters, digits, dot, dash or underscore`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const fileFor = (root, label) => path.join(root, exports.BASELINE_DIR, `${label}.json`);
|
|
75
|
+
/**
|
|
76
|
+
* @implements A-SPEC-134
|
|
77
|
+
* The tamper-evidence digest a baseline is anchored by: sha256 of the key-sorted files map. NOT the
|
|
78
|
+
* whole record (takenAt is a timestamp, not content) — only the file→hash mapping the scope narrows
|
|
79
|
+
* against. Editing any file entry changes this digest, and the anchor on the append-only chain will
|
|
80
|
+
* no longer match. Pure, key-order-insensitive.
|
|
81
|
+
*/
|
|
82
|
+
function baselineDigest(files) {
|
|
83
|
+
const sorted = {};
|
|
84
|
+
for (const k of Object.keys(files).sort())
|
|
85
|
+
sorted[k] = files[k];
|
|
86
|
+
return `sha256:${(0, node_crypto_1.createHash)('sha256').update(JSON.stringify(sorted), 'utf8').digest('hex')}`;
|
|
87
|
+
}
|
|
88
|
+
const ledgerFor = (root) => path.join(root, '.ax', 'ledger', 'provenance.jsonl');
|
|
89
|
+
/** Write (or replace) a labelled baseline. One label holds exactly one reference point. */
|
|
90
|
+
function writeBaseline(root, label, files) {
|
|
91
|
+
assertLabel(label);
|
|
92
|
+
// Keys sorted so the stored file is byte-stable for the same input; a diffable baseline is easier
|
|
93
|
+
// to inspect, and instability here would show up as noise in any tooling that reads it.
|
|
94
|
+
const sorted = {};
|
|
95
|
+
for (const k of Object.keys(files).sort())
|
|
96
|
+
sorted[k] = files[k];
|
|
97
|
+
const baseline = { label, takenAt: new Date().toISOString(), files: sorted };
|
|
98
|
+
fs.mkdirSync(path.dirname(fileFor(root, label)), { recursive: true });
|
|
99
|
+
fs.writeFileSync(fileFor(root, label), `${JSON.stringify(baseline, null, 2)}\n`);
|
|
100
|
+
// @implements A-SPEC-134
|
|
101
|
+
// Anchor the digest on the append-only provenance chain. A ledger failure must NOT fail the
|
|
102
|
+
// baseline write — the baseline stays usable, just UNANCHORED, which read-verification treats as
|
|
103
|
+
// untrusted → full scope (the safe direction). Never trade a working baseline for a crash here.
|
|
104
|
+
try {
|
|
105
|
+
// @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
|
|
106
|
+
new ledger_store_1.FileLedgerStore(path.dirname(ledgerFor(root))).append({
|
|
107
|
+
ts: baseline.takenAt, actor: 'holmes-kit', kind: 'baseline-recorded',
|
|
108
|
+
summary: `baseline ${label} recorded (${Object.keys(sorted).length} files)`,
|
|
109
|
+
inputs: [label, baselineDigest(sorted)], rationale: '', authorization: '',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch { /* unanchored baseline degrades to untrusted on read, never throws the write */ }
|
|
113
|
+
return baseline;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* @implements A-SPEC-134
|
|
117
|
+
* Is this baseline still the one that was recorded? Recompute its digest from the on-disk files and
|
|
118
|
+
* confirm a `baseline-recorded` chain entry carries that (label, digest). A hand-edit changes the
|
|
119
|
+
* digest and no entry matches → false. A missing baseline or missing/unreadable chain → false:
|
|
120
|
+
* cannot-verify is not permission to narrow (the caller degrades to full scope).
|
|
121
|
+
*/
|
|
122
|
+
function isBaselineTrusted(root, label, ledgerFile) {
|
|
123
|
+
const baseline = readBaseline(root, label);
|
|
124
|
+
if (!baseline)
|
|
125
|
+
return false;
|
|
126
|
+
const digest = baselineDigest(baseline.files);
|
|
127
|
+
// @implements A-SPEC-148
|
|
128
|
+
// The anchor is searched across EVERY replica chain. Writes now land in this machine's own chain,
|
|
129
|
+
// so scanning only the legacy file would fail to find an anchor this very process just wrote.
|
|
130
|
+
let events;
|
|
131
|
+
try {
|
|
132
|
+
const { FileLedgerStore } = require('../governance/ledger-store');
|
|
133
|
+
events = new FileLedgerStore(path.dirname(ledgerFile)).loadAll();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// @implements A-SPEC-134
|
|
139
|
+
// The anchor is only trustworthy if the chain it sits on is INTACT. Adversarial review: an attacker
|
|
140
|
+
// who hand-appends a `baseline-recorded` entry with a forged digest would otherwise be "found" here
|
|
141
|
+
// — but forging a valid hash link needs HOLMES_LEDGER_KEY, so a hand-append breaks verify(). Tie
|
|
142
|
+
// baseline trust to chain integrity: a broken chain trusts NO baseline (degrade to full scope),
|
|
143
|
+
// which is the safe direction and closes the within-turn window the Stop-hook ART-2 check only
|
|
144
|
+
// caught at turn end.
|
|
145
|
+
try {
|
|
146
|
+
// @implements A-SPEC-148 — the anchor is only trustworthy if EVERY chain is intact, not just one.
|
|
147
|
+
const { FileLedgerStore } = require('../governance/ledger-store');
|
|
148
|
+
if (!new FileLedgerStore(path.dirname(ledgerFile)).verifyAll().ok)
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
for (const evt of events) {
|
|
155
|
+
if (evt.kind === 'baseline-recorded' && Array.isArray(evt.inputs)
|
|
156
|
+
&& evt.inputs.includes(label) && evt.inputs.includes(digest))
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read a baseline, or null when there is none.
|
|
163
|
+
*
|
|
164
|
+
* ABSENT AND EMPTY ARE DIFFERENT. Empty means "nothing existed when this was taken"; absent means
|
|
165
|
+
* "there is no reference point at all". Returning an empty baseline for a missing one would let a
|
|
166
|
+
* caller narrow its scope against a reference it never took — the silently-wrong direction. A corrupt
|
|
167
|
+
* or unreadable file is treated as absent for the same reason a cold cache is: it must degrade the
|
|
168
|
+
* run, never abort it.
|
|
169
|
+
*/
|
|
170
|
+
function readBaseline(root, label) {
|
|
171
|
+
try {
|
|
172
|
+
assertLabel(label);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null; // an invalid label cannot name an existing baseline
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(fs.readFileSync(fileFor(root, label), 'utf8'));
|
|
179
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.files !== 'object' || parsed.files === null)
|
|
180
|
+
return null;
|
|
181
|
+
return { label, takenAt: String(parsed.takenAt ?? ''), files: parsed.files };
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* May this run become the reference point for "since the last verified state"?
|
|
189
|
+
*
|
|
190
|
+
* ONLY a run that actually executed and passed. A red suite must not become the baseline — every
|
|
191
|
+
* later scope would then be narrowed against a state nobody verified — and a skipped run is not
|
|
192
|
+
* evidence of anything at all. This is exactly the condition that already gates writing test
|
|
193
|
+
* evidence, extracted so both branches are pinned by a test rather than only the negative one: a
|
|
194
|
+
* fixture project has no runnable suite, so the passing branch is otherwise never exercised.
|
|
195
|
+
*/
|
|
196
|
+
function shouldRecordBaseline(result) {
|
|
197
|
+
return result.passed && !result.skipped;
|
|
198
|
+
}
|
|
199
|
+
/** Every label that exists, sorted. */
|
|
200
|
+
function listBaselines(root) {
|
|
201
|
+
try {
|
|
202
|
+
return fs.readdirSync(path.join(root, exports.BASELINE_DIR))
|
|
203
|
+
.filter((f) => f.endsWith('.json'))
|
|
204
|
+
.map((f) => f.slice(0, -'.json'.length))
|
|
205
|
+
.sort();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|