@dzhechkov/harness-core 0.7.2 → 0.7.3
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/.dz-manifest.json +101 -41
- package/README.md +1 -1
- package/dist/event-chain.d.ts +50 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +31 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/name-check.d.ts +98 -0
- package/dist/name-check.d.ts.map +1 -0
- package/dist/name-check.js +333 -0
- package/dist/name-check.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +25 -4
- package/dist/operations.js.map +1 -1
- package/dist/provenance.d.ts +100 -92
- package/dist/provenance.d.ts.map +1 -1
- package/dist/provenance.js +122 -122
- package/dist/provenance.js.map +1 -1
- package/dist/recall-domain-boost.d.ts +10 -3
- package/dist/recall-domain-boost.d.ts.map +1 -1
- package/dist/recall-domain-boost.js +7 -0
- package/dist/recall-domain-boost.js.map +1 -1
- package/dist/recall-hook-policy.d.ts +15 -0
- package/dist/recall-hook-policy.d.ts.map +1 -1
- package/dist/recall-hook-policy.js +59 -0
- package/dist/recall-hook-policy.js.map +1 -1
- package/dist/recap.d.ts +146 -0
- package/dist/recap.d.ts.map +1 -0
- package/dist/recap.js +346 -0
- package/dist/recap.js.map +1 -0
- package/dist/retro.d.ts +131 -0
- package/dist/retro.d.ts.map +1 -0
- package/dist/retro.js +207 -0
- package/dist/retro.js.map +1 -0
- package/dist/score.d.ts +21 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +44 -3
- package/dist/score.js.map +1 -1
- package/dist/vector-tier.d.ts +54 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +69 -8
- package/dist/vector-tier.js.map +1 -1
- package/package.json +5 -5
- package/sbom.json +190 -40
- package/src/event-chain.ts +64 -0
- package/src/index.ts +12 -0
- package/src/name-check.ts +331 -0
- package/src/operations.ts +26 -5
- package/src/provenance.ts +217 -0
- package/src/recall-domain-boost.ts +10 -3
- package/src/recall-hook-policy.ts +60 -0
- package/src/recap.ts +462 -0
- package/src/score.ts +53 -3
- package/src/vector-tier.ts +109 -10
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz name-check` — is this name free, before a line of code is written?
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, stated plainly: twice in one day a name collision broke the build outright.
|
|
5
|
+
* `dz retro` was already a command (the per-session process retro), and its star re-export clash
|
|
6
|
+
* stopped the CLI from importing at all; `decideProvenance` was already an export (npm provenance),
|
|
7
|
+
* and the build went red mid-feature. MEASURED 2026-08-24: `case 'retro':` is in the dispatcher, and
|
|
8
|
+
* both `buildRetro` and `decideProvenance` are in the core's 1020-name public surface — so BOTH were
|
|
9
|
+
* answerable before any code, and nobody asked.
|
|
10
|
+
*
|
|
11
|
+
* The owner's question was "what guarantees you will check?". An agent's intention is layer 4 on this
|
|
12
|
+
* project's cost-of-detection ladder: it works while remembered and is silent when it lapses. A
|
|
13
|
+
* command is the guarantee; a promise is not.
|
|
14
|
+
*
|
|
15
|
+
* PURE: no filesystem here. The scan runs in the CLI and arrives as facts — see ADR-001 for why
|
|
16
|
+
* those facts come from SOURCE and never from `dist`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What kind of name was asked about. */
|
|
20
|
+
export type NameKind = 'command' | 'module' | 'export';
|
|
21
|
+
|
|
22
|
+
export interface NameQuery {
|
|
23
|
+
readonly kind: NameKind;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NameFacts {
|
|
28
|
+
/** Command names found in the dispatcher and in the help block. */
|
|
29
|
+
readonly commands: ReadonlySet<string>;
|
|
30
|
+
/** Module basenames found as `src/<basename>.ts` in any workspace package. */
|
|
31
|
+
readonly modules: ReadonlyMap<string, string>;
|
|
32
|
+
/** Exported identifiers found by scanning SOURCE, mapped to the file that declares them. */
|
|
33
|
+
readonly exports: ReadonlyMap<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* True when the scan itself could not be performed (no workspace found, unreadable tree).
|
|
36
|
+
* A scan that did not run must never report "free" — that is the whole failure this command
|
|
37
|
+
* exists to prevent, one level up.
|
|
38
|
+
*/
|
|
39
|
+
readonly scanFailed?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* What the sweep actually saw. Reported to the operator, because "one empty .ts file in a
|
|
42
|
+
* lookalike directory" and "the real workspace" both used to satisfy a bare did-it-open-a-file
|
|
43
|
+
* test, and the second question a reviewer asked was exactly that (2026-08-24).
|
|
44
|
+
*/
|
|
45
|
+
readonly scanned?: { readonly packages: number; readonly files: number; readonly exports: number; readonly commands: number };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type NameVerdict = 'free' | 'taken';
|
|
49
|
+
|
|
50
|
+
export interface NameResolution {
|
|
51
|
+
readonly kind: NameKind;
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly verdict: NameVerdict;
|
|
54
|
+
/** Where the collision lives, when there is one. Empty for a free name. */
|
|
55
|
+
readonly where: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type NameOutcome = 'free' | 'taken' | 'not-established';
|
|
59
|
+
|
|
60
|
+
export interface NameDecision {
|
|
61
|
+
readonly outcome: NameOutcome;
|
|
62
|
+
/** 0 every name free · 1 at least one taken · 2 nothing asked or the scan did not run. */
|
|
63
|
+
readonly exit: 0 | 1 | 2;
|
|
64
|
+
readonly results: readonly NameResolution[];
|
|
65
|
+
readonly reason: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Is a proposed name already spoken for? */
|
|
69
|
+
export function classifyName(query: NameQuery, facts: NameFacts): NameResolution {
|
|
70
|
+
const name = query.name.trim();
|
|
71
|
+
const at = (verdict: NameVerdict, where: string): NameResolution => ({ kind: query.kind, name, verdict, where });
|
|
72
|
+
if (query.kind === 'command') {
|
|
73
|
+
return facts.commands.has(name) ? at('taken', 'already dispatched as a dz command') : at('free', '');
|
|
74
|
+
}
|
|
75
|
+
if (query.kind === 'module') {
|
|
76
|
+
const file = facts.modules.get(name);
|
|
77
|
+
return file === undefined ? at('free', '') : at('taken', file);
|
|
78
|
+
}
|
|
79
|
+
const file = facts.exports.get(name);
|
|
80
|
+
return file === undefined ? at('free', '') : at('taken', file);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The whole verdict.
|
|
85
|
+
*
|
|
86
|
+
* Two ways to be not-established, and neither returns zero: nothing was asked, or the scan did not
|
|
87
|
+
* run. "I checked nothing" and "nothing is taken" are different answers, and a gate that conflates
|
|
88
|
+
* them is green exactly when it is blind — the defect measured on `dz sync` (0/0, exit 0) and on the
|
|
89
|
+
* source scanner that printed `github: 0` for a 401.
|
|
90
|
+
*/
|
|
91
|
+
export function decideNameCheck(queries: readonly NameQuery[], facts: NameFacts): NameDecision {
|
|
92
|
+
if (facts.scanFailed === true) {
|
|
93
|
+
return { outcome: 'not-established', exit: 2, results: [], reason: 'the workspace could not be scanned, so no name was checked — this is not a clean bill' };
|
|
94
|
+
}
|
|
95
|
+
const asked = queries.filter((q) => q.name.trim() !== '');
|
|
96
|
+
if (asked.length === 0) {
|
|
97
|
+
return { outcome: 'not-established', exit: 2, results: [], reason: 'no name was asked about — pass --command, --module or --export' };
|
|
98
|
+
}
|
|
99
|
+
// ESTABLISHMENT IS PER KIND. A question about an export cannot be answered by a sweep that found
|
|
100
|
+
// no exports at all; a question about a command cannot be answered without having seen a CLI. A
|
|
101
|
+
// sweep of a lookalike tree satisfies neither, and the honest verdict there is "not established",
|
|
102
|
+
// not "free".
|
|
103
|
+
const unanswerable = asked.filter((q) => {
|
|
104
|
+
if (q.kind === 'command') return facts.commands.size === 0;
|
|
105
|
+
if (q.kind === 'module') return facts.modules.size === 0;
|
|
106
|
+
return facts.exports.size === 0;
|
|
107
|
+
});
|
|
108
|
+
if (unanswerable.length > 0) {
|
|
109
|
+
const kinds = [...new Set(unanswerable.map((q) => q.kind))].join(', ');
|
|
110
|
+
return {
|
|
111
|
+
outcome: 'not-established',
|
|
112
|
+
exit: 2,
|
|
113
|
+
results: [],
|
|
114
|
+
reason: `the sweep found nothing of kind: ${kinds} — a tree with no ${kinds} cannot answer a question about one, and reporting "free" from it would be a clean bill from an empty room`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const results = asked.map((q) => classifyName(q, facts));
|
|
118
|
+
const taken = results.filter((r) => r.verdict === 'taken');
|
|
119
|
+
if (taken.length > 0) {
|
|
120
|
+
return {
|
|
121
|
+
outcome: 'taken',
|
|
122
|
+
exit: 1,
|
|
123
|
+
results,
|
|
124
|
+
reason: `${taken.length} of ${results.length} name(s) already spoken for — rename before writing, not after the build goes red`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { outcome: 'free', exit: 0, results, reason: `all ${results.length} name(s) are free` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['scanned']): string[] {
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
if (scanned !== undefined) {
|
|
133
|
+
// Printed always, pass or fail: the operator must be able to see that the sweep looked at a real
|
|
134
|
+
// workspace and not at a directory that merely has the right shape.
|
|
135
|
+
out.push(` swept ${scanned.packages} package(s), ${scanned.files} source file(s) — ${scanned.exports} export(s), ${scanned.commands} command(s)`);
|
|
136
|
+
}
|
|
137
|
+
for (const r of decision.results) {
|
|
138
|
+
out.push(r.verdict === 'taken'
|
|
139
|
+
? ` [taken] ${r.kind} ${r.name} — ${r.where}`
|
|
140
|
+
: ` [free] ${r.kind} ${r.name}`);
|
|
141
|
+
}
|
|
142
|
+
const label = decision.outcome === 'free' ? 'FREE' : decision.outcome === 'taken' ? 'TAKEN' : 'NOT ESTABLISHED';
|
|
143
|
+
out.push(`dz name-check: ${label} — ${decision.reason}`);
|
|
144
|
+
if (decision.outcome === 'free') {
|
|
145
|
+
// Said on the passing path, because that is where the limit gets forgotten: the scan reads
|
|
146
|
+
// declarations, so a re-export under a different name (`export { a as b }`) is invisible to it.
|
|
147
|
+
out.push(' note: this reads declarations in source. A re-export under a different name is not visible here — the build still owns that case.');
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Exported identifiers declared in one TypeScript source file.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately a scanner over DECLARATIONS, not a loader of the built package (ADR-001): a stale
|
|
156
|
+
* `dist` answers "free" about a name the source already took, and answers it confidently. MEASURED
|
|
157
|
+
* 2026-08-22 in this repo — half an hour of live runs against a previous build while `tsc` was red.
|
|
158
|
+
*/
|
|
159
|
+
/**
|
|
160
|
+
* Source with comments blanked out, quotes respected.
|
|
161
|
+
*
|
|
162
|
+
* Trivia may sit between ANY two tokens: an `export` followed by a block comment and then `class`
|
|
163
|
+
* was reported FREE, because the declaration pattern expects the keyword to be adjacent
|
|
164
|
+
* (cross-family review round 4, codex gpt-5.6-sol, 2026-08-24). Rather than widen the pattern for
|
|
165
|
+
* one shape of trivia, the trivia is removed first — which fixes the whole class at once.
|
|
166
|
+
*
|
|
167
|
+
* Newlines are PRESERVED so line-anchored patterns keep their anchors.
|
|
168
|
+
*/
|
|
169
|
+
export function stripComments(source: string): string {
|
|
170
|
+
let out = '';
|
|
171
|
+
let i = 0;
|
|
172
|
+
let quote: string | null = null;
|
|
173
|
+
// The previous significant character decides whether a `/` opens a REGEX or divides. Without that
|
|
174
|
+
// distinction the regex literal `/[/*]/` reads as a block-comment opener and everything after it
|
|
175
|
+
// is blanked — so a later `export const Taken = 2;` vanished and was reported FREE (cross-family
|
|
176
|
+
// review round 5, codex gpt-5.6-sol, 2026-08-24).
|
|
177
|
+
let prev = '';
|
|
178
|
+
const REGEX_MAY_FOLLOW = new Set(['', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '%', '~', '^', '<', '>', '\n']);
|
|
179
|
+
const KEYWORD_BEFORE_REGEX = /\b(return|typeof|case|in|of|new|delete|void|instanceof|do|else|yield|await)\s*$/;
|
|
180
|
+
while (i < source.length) {
|
|
181
|
+
const c = source[i] as string;
|
|
182
|
+
const next = source[i + 1];
|
|
183
|
+
if (quote !== null) {
|
|
184
|
+
out += c;
|
|
185
|
+
if (c === '\\') { out += next ?? ''; i += 2; continue; }
|
|
186
|
+
if (c === quote) quote = null;
|
|
187
|
+
i++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; prev = c; continue; }
|
|
191
|
+
// Comment forms are checked FIRST because they are unambiguous: a regex literal can begin
|
|
192
|
+
// with neither `/` nor `*`. Putting the regex check first made a block comment at the start
|
|
193
|
+
// of a line look like a literal and survive the strip — a regression caught by its own test.
|
|
194
|
+
if (c === '/' && next === '/') {
|
|
195
|
+
while (i < source.length && source[i] !== '\n') { out += ' '; i++; }
|
|
196
|
+
prev = '\n';
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (c === '/' && next === '*') {
|
|
200
|
+
const close = source.indexOf('*/', i + 2);
|
|
201
|
+
if (close === -1) {
|
|
202
|
+
// An UNTERMINATED block comment is not a comment — it is a misread. Blanking to EOF turned
|
|
203
|
+
// every heuristic slip into a whole-file loss: `if (true) /[/*]/.test('*')` was read as an
|
|
204
|
+
// opener after `)`, and every export below it vanished and was reported FREE (cross-family
|
|
205
|
+
// review round 6, codex gpt-5.6-sol, 2026-08-24). Real source with an unclosed comment does
|
|
206
|
+
// not compile, so treating the text as text is strictly the safer reading: the worst case
|
|
207
|
+
// becomes a false TAKEN, which is conservative, instead of a false FREE, which is a lie.
|
|
208
|
+
out += c;
|
|
209
|
+
prev = c;
|
|
210
|
+
i++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const stop = close + 2;
|
|
214
|
+
for (let k = i; k < stop; k++) out += source[k] === '\n' ? '\n' : ' ';
|
|
215
|
+
i = stop;
|
|
216
|
+
prev = ' ';
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (c === '/' && (REGEX_MAY_FOLLOW.has(prev) || KEYWORD_BEFORE_REGEX.test(out))) {
|
|
220
|
+
// A regex literal: copy it verbatim to its unescaped closing slash. A `/` inside a character
|
|
221
|
+
// class does not close it.
|
|
222
|
+
let j = i + 1;
|
|
223
|
+
let inClass = false;
|
|
224
|
+
let closed = false;
|
|
225
|
+
while (j < source.length) {
|
|
226
|
+
const d = source[j] as string;
|
|
227
|
+
if (d === '\\') { j += 2; continue; }
|
|
228
|
+
if (d === '\n') break; // an unterminated literal is not one
|
|
229
|
+
if (d === '[') inClass = true;
|
|
230
|
+
else if (d === ']') inClass = false;
|
|
231
|
+
else if (d === '/' && !inClass) { closed = true; j++; break; }
|
|
232
|
+
j++;
|
|
233
|
+
}
|
|
234
|
+
if (closed) { out += source.slice(i, j); prev = '/'; i = j; continue; }
|
|
235
|
+
// Not a regex after all — fall through to the comment checks below.
|
|
236
|
+
}
|
|
237
|
+
out += c;
|
|
238
|
+
if (!/\s/.test(c) || c === '\n') prev = c;
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function exportedNamesIn(rawSource: string): string[] {
|
|
245
|
+
const source = stripComments(rawSource);
|
|
246
|
+
const names = new Set<string>();
|
|
247
|
+
// Non-binding declarations export exactly one name. The KEYWORD SET is the correctness of this
|
|
248
|
+
// line just as much as the modifier set was: `export namespace Taken {}` was reported FREE because
|
|
249
|
+
// `namespace` was missing (cross-family review round 3, codex gpt-5.6-sol, 2026-08-24). `module` is
|
|
250
|
+
// the legacy spelling of the same thing and is admitted with it.
|
|
251
|
+
const decl = /^\s*export\s+(?:(?:declare|abstract|async)\s+)*(?:function|class|interface|type|enum|namespace|module)\s+([A-Za-z_$][\w$]*)/gm;
|
|
252
|
+
for (let m = decl.exec(source); m !== null; m = decl.exec(source)) if (m[1] !== undefined) names.add(m[1]);
|
|
253
|
+
|
|
254
|
+
// `const`/`let`/`var` can declare MANY names in one statement, and only the first was captured:
|
|
255
|
+
// `export const Seen = 1, Taken = 2;` reported `Taken` FREE (cross-family review round 2, codex
|
|
256
|
+
// gpt-5.6-sol, 2026-08-24). Destructuring exports names too. So the declarator list is parsed.
|
|
257
|
+
const binding = /^\s*export\s+(?:declare\s+)*(?:const|let|var)\s+/gm;
|
|
258
|
+
for (let m = binding.exec(source); m !== null; m = binding.exec(source)) {
|
|
259
|
+
for (const n of declaredBindingNames(source.slice(m.index + m[0].length))) names.add(n);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// `export { a, b as c }` — the EXPORTED name is what a consumer collides with, so for an alias it
|
|
263
|
+
// is the right-hand side. A bare list contributes its own names.
|
|
264
|
+
const list = /^\s*export\s*\{([^}]*)\}/gm;
|
|
265
|
+
for (let m = list.exec(source); m !== null; m = list.exec(source)) {
|
|
266
|
+
for (const raw of (m[1] ?? '').split(',')) {
|
|
267
|
+
const part = raw.trim();
|
|
268
|
+
if (part === '' || part.startsWith('*')) continue;
|
|
269
|
+
const alias = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(part);
|
|
270
|
+
const bare = /^(?:type\s+)?([A-Za-z_$][\w$]*)$/.exec(part);
|
|
271
|
+
const picked = alias?.[1] ?? bare?.[1];
|
|
272
|
+
if (picked !== undefined && picked !== 'default') names.add(picked);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return [...names];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const BINDING_NOISE: ReadonlySet<string> = new Set(['readonly', 'as', 'const', 'await', 'typeof']);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Every name bound by one `const`/`let`/`var` statement, given the text just after the keyword.
|
|
282
|
+
*
|
|
283
|
+
* Walks to the statement end at depth zero, splits the declarator list on top-level commas, and for
|
|
284
|
+
* each declarator takes the identifiers before its first top-level `=` or `:` — so an initialiser
|
|
285
|
+
* and a type annotation contribute nothing, while `a, b`, `{ a, b }` and `[a, b]` all do.
|
|
286
|
+
*/
|
|
287
|
+
function declaredBindingNames(after: string): string[] {
|
|
288
|
+
let depth = 0;
|
|
289
|
+
let end = after.length;
|
|
290
|
+
for (let i = 0; i < after.length; i++) {
|
|
291
|
+
const c = after[i] as string;
|
|
292
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
293
|
+
else if (c === ')' || c === ']' || c === '}') { if (depth === 0) { end = i; break; } depth--; }
|
|
294
|
+
else if (c === ';' && depth === 0) { end = i; break; }
|
|
295
|
+
}
|
|
296
|
+
const stmt = after.slice(0, end);
|
|
297
|
+
const parts: string[] = [];
|
|
298
|
+
let level = 0;
|
|
299
|
+
let start = 0;
|
|
300
|
+
for (let i = 0; i < stmt.length; i++) {
|
|
301
|
+
const c = stmt[i] as string;
|
|
302
|
+
if (c === '(' || c === '[' || c === '{') level++;
|
|
303
|
+
else if (c === ')' || c === ']' || c === '}') level--;
|
|
304
|
+
else if (c === ',' && level === 0) { parts.push(stmt.slice(start, i)); start = i + 1; }
|
|
305
|
+
}
|
|
306
|
+
parts.push(stmt.slice(start));
|
|
307
|
+
|
|
308
|
+
const out: string[] = [];
|
|
309
|
+
for (const part of parts) {
|
|
310
|
+
let head = part;
|
|
311
|
+
let lvl = 0;
|
|
312
|
+
for (let i = 0; i < part.length; i++) {
|
|
313
|
+
const c = part[i] as string;
|
|
314
|
+
if (c === '(' || c === '[' || c === '{') lvl++;
|
|
315
|
+
else if (c === ')' || c === ']' || c === '}') lvl--;
|
|
316
|
+
else if ((c === '=' || c === ':') && lvl === 0) { head = part.slice(0, i); break; }
|
|
317
|
+
}
|
|
318
|
+
for (const m of head.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
319
|
+
if (!BINDING_NOISE.has(m[0])) out.push(m[0]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Command names a CLI source dispatches. The help block is scanned separately by the caller. */
|
|
326
|
+
export function dispatchedCommandsIn(source: string): string[] {
|
|
327
|
+
const names = new Set<string>();
|
|
328
|
+
const re = /^\s*case\s+'([a-z][a-z0-9-]*)':/gm;
|
|
329
|
+
for (let m = re.exec(source); m !== null; m = re.exec(source)) if (m[1] !== undefined) names.add(m[1]);
|
|
330
|
+
return [...names];
|
|
331
|
+
}
|
package/src/operations.ts
CHANGED
|
@@ -1070,18 +1070,39 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1070
1070
|
// Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
|
|
1071
1071
|
// fault, and reporting it would train the reader to ignore this line.
|
|
1072
1072
|
try {
|
|
1073
|
-
const { verifyEventChainText, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
|
|
1073
|
+
const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
|
|
1074
1074
|
for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
|
|
1075
1075
|
const p = join(root, '.dz', rel);
|
|
1076
1076
|
if (!existsSync(p)) continue;
|
|
1077
|
-
const
|
|
1077
|
+
const text = readFileSync(p, 'utf-8');
|
|
1078
|
+
const v = verifyEventChainText(text);
|
|
1078
1079
|
if (v.chained === 0 || v.ok) continue;
|
|
1080
|
+
const total = text.split('\n').filter((l) => l.trim() !== '').length;
|
|
1081
|
+
const age = classifyChainDefects(v, total);
|
|
1082
|
+
const named = `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}`;
|
|
1083
|
+
// A break that an unbroken run has already outlived is not a reason to distrust today's
|
|
1084
|
+
// records. Reporting both alike made this line PERMANENTLY red for four weeks — MEASURED
|
|
1085
|
+
// 2026-08-24: every defect in both logs is historical, with 998 of 1138 rows in one and 88 of
|
|
1086
|
+
// 426 in the other forming an unbroken run after the last of them. The verdict was true of the
|
|
1087
|
+
// file and false of the present, and a red nobody can act on is a red nobody reads.
|
|
1088
|
+
if (age.inRun.length === 0 && age.runRecords > 0) {
|
|
1089
|
+
checks.push({
|
|
1090
|
+
name: `evidence chain (.dz/${rel})`,
|
|
1091
|
+
ok: true,
|
|
1092
|
+
// The COUNT carries the meaning, and is printed first for that reason: "1 record forms an
|
|
1093
|
+
// unbroken run" is true and says almost nothing, while 998 says a great deal. Naming the
|
|
1094
|
+
// position without the count would overclaim on the reader's behalf (cross-family review,
|
|
1095
|
+
// codex gpt-5.6-sol, 2026-08-24).
|
|
1096
|
+
detail:
|
|
1097
|
+
`${named} — all BEFORE the current run: the last ${age.runRecords} record(s), from L${age.runFrom}, are unbroken, ` +
|
|
1098
|
+
`so verdicts over those ${age.runRecords} are sound. The break itself cannot be un-happened. Scope: ${EVENT_CHAIN_SCOPE}`,
|
|
1099
|
+
});
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1079
1102
|
checks.push({
|
|
1080
1103
|
name: `evidence chain (.dz/${rel})`,
|
|
1081
1104
|
ok: false,
|
|
1082
|
-
detail:
|
|
1083
|
-
`${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}` +
|
|
1084
|
-
` — learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
|
|
1105
|
+
detail: `${named} — with NO sound records after them: learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
|
|
1085
1106
|
});
|
|
1086
1107
|
}
|
|
1087
1108
|
} catch {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provenance gate — nothing leaves this machine citing a source that may not.
|
|
3
|
+
*
|
|
4
|
+
* It checks PROVENANCE, not words. A denylist of forbidden phrases is an enumeration, and it loses
|
|
5
|
+
* to the first confidential note it has never heard of; the `.gitignore` in this repository carries
|
|
6
|
+
* a comment warning about exactly that mistake. An allowlist of SOURCES wins by construction: an
|
|
7
|
+
* unknown source is refused because it is unknown.
|
|
8
|
+
*
|
|
9
|
+
* Two things this module deliberately does NOT do, both named in ADR-001 rather than implied:
|
|
10
|
+
* - it does not read CONTENT, so confidential text pasted by hand into an allowed file inherits
|
|
11
|
+
* that file's permission;
|
|
12
|
+
* - it cannot see a paraphrase with no citation. It proves what was CITED, never what was known.
|
|
13
|
+
* The last line against both is a person reading the draft, and that is the design, not a gap.
|
|
14
|
+
*
|
|
15
|
+
* PURE: no filesystem, no git, no clock. The oracle runs in the CLI and arrives as a fact — a
|
|
16
|
+
* verdict that needs a real repository to reproduce is a verdict no test can pin.
|
|
17
|
+
*
|
|
18
|
+
* See features/provenance-gate/03_adr/ for the decisions and the measurements behind them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Why one claim was refused. A CLOSED set, so a reason is machine-readable and never prose. */
|
|
22
|
+
export type SourceVerdict =
|
|
23
|
+
| 'allowed'
|
|
24
|
+
/** The claim names no source at all. A claim with no source is the thing we must not publish. */
|
|
25
|
+
| 'no-source'
|
|
26
|
+
/** The manifest did not declare what kind of source this is — never inferred from its shape. */
|
|
27
|
+
| 'unknown-kind'
|
|
28
|
+
/** A store record that the TRACKED public list does not name. Default-deny. */
|
|
29
|
+
| 'not-marked-public'
|
|
30
|
+
/** The path does not resolve. You cannot cite what does not exist. */
|
|
31
|
+
| 'unresolvable'
|
|
32
|
+
/** Resolved outside the repository — a symlink out of the tree lands here too. */
|
|
33
|
+
| 'outside-repo'
|
|
34
|
+
/** Git says this path is ignored: the owner's own boundary refuses it. */
|
|
35
|
+
| 'ignored-path'
|
|
36
|
+
/** Not tracked by git — nobody has reviewed it, so "not ignored" proves nothing about it. */
|
|
37
|
+
| 'untracked'
|
|
38
|
+
/** Tracked, but carrying uncommitted changes: its CURRENT contents went through no review. */
|
|
39
|
+
| 'uncommitted';
|
|
40
|
+
|
|
41
|
+
export type SourceProvenanceOutcome = 'allowed' | 'blocked' | 'not-established';
|
|
42
|
+
|
|
43
|
+
/** One claim in a draft, and where it came from. */
|
|
44
|
+
export interface SourceClaim {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
/** `path` — a file in the repository. `record` — an addressed row in a `.dz` store. */
|
|
47
|
+
readonly kind?: string;
|
|
48
|
+
readonly source?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SourceManifest {
|
|
52
|
+
readonly version: number;
|
|
53
|
+
readonly draft: string;
|
|
54
|
+
readonly claims: readonly SourceClaim[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SourceProvenanceFacts {
|
|
58
|
+
/**
|
|
59
|
+
* Paths git reported as ignored. `null` means THE ORACLE DID NOT RUN — a different fact from
|
|
60
|
+
* "nothing is ignored", and the one a naive gate reads as "everything is allowed". Measured in
|
|
61
|
+
* this repo on a sibling command: `dz sync` prints `0/0` and exits 0 when it compared nothing.
|
|
62
|
+
*/
|
|
63
|
+
readonly ignoredPaths: ReadonlySet<string> | null;
|
|
64
|
+
/**
|
|
65
|
+
* Record addresses declared public in a GIT-TRACKED file. Not a `visibility` field inside the
|
|
66
|
+
* record: `.dz/` is gitignored, so a marker there appears in no diff, and the process that writes
|
|
67
|
+
* the draft can write it too. MEASURED 2026-08-22: `grep -r '"visibility"' .dz/` returns 0 — the
|
|
68
|
+
* field does not exist, and introducing it would have made "the owner opted in" mean "the
|
|
69
|
+
* generator marked itself" (ADR-001).
|
|
70
|
+
*/
|
|
71
|
+
readonly publicRecords: ReadonlySet<string>;
|
|
72
|
+
/** Each `path` source resolved through the filesystem; `null` = missing or outside the repo. */
|
|
73
|
+
readonly resolved: ReadonlyMap<string, string | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Resolved paths git tracks. "Not ignored" is NOT "reviewed": a file the drafting process wrote a
|
|
76
|
+
* second ago is neither, and the first version cleared it while calling it "a tracked path"
|
|
77
|
+
* (cross-family review round 3, codex `gpt-5.6-sol`, 2026-08-22).
|
|
78
|
+
*/
|
|
79
|
+
readonly trackedPaths: ReadonlySet<string>;
|
|
80
|
+
/** Tracked paths with uncommitted changes — committed is what "reviewed" means here. */
|
|
81
|
+
readonly dirtyPaths: ReadonlySet<string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SourceClaimResolution {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly source: string | null;
|
|
87
|
+
readonly verdict: SourceVerdict;
|
|
88
|
+
readonly detail: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SourceProvenanceDecision {
|
|
92
|
+
readonly outcome: SourceProvenanceOutcome;
|
|
93
|
+
/** 0 allowed · 1 blocked · 3 not established. A zero is only ever a proven pass. */
|
|
94
|
+
readonly exit: 0 | 1 | 3;
|
|
95
|
+
readonly claims: readonly SourceClaimResolution[];
|
|
96
|
+
readonly reason: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse a manifest. Unparseable is `null` — which the caller must NOT read as "empty". */
|
|
100
|
+
export function parseSourceManifest(text: string): SourceManifest | null {
|
|
101
|
+
let raw: unknown;
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(text);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (raw === null || typeof raw !== 'object') return null;
|
|
108
|
+
const o = raw as Record<string, unknown>;
|
|
109
|
+
if (!Array.isArray(o['claims'])) return null;
|
|
110
|
+
const claims: SourceClaim[] = [];
|
|
111
|
+
for (const c of o['claims'] as unknown[]) {
|
|
112
|
+
if (c === null || typeof c !== 'object') return null;
|
|
113
|
+
const cc = c as Record<string, unknown>;
|
|
114
|
+
claims.push({
|
|
115
|
+
id: typeof cc['id'] === 'string' ? cc['id'] : '',
|
|
116
|
+
...(typeof cc['kind'] === 'string' ? { kind: cc['kind'] } : {}),
|
|
117
|
+
...(typeof cc['source'] === 'string' ? { source: cc['source'] } : {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
version: typeof o['version'] === 'number' ? o['version'] : 1,
|
|
122
|
+
draft: typeof o['draft'] === 'string' ? o['draft'] : '',
|
|
123
|
+
claims,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Classify ONE claim's source.
|
|
129
|
+
*
|
|
130
|
+
* Order matters and is load-bearing. The KIND is read from the manifest and never guessed from the
|
|
131
|
+
* string: `.dz/` holds 119 git-TRACKED files (measured), so `.dz/guard-audit.jsonl` is not ignored
|
|
132
|
+
* and a shape-based guess would let it through as an ordinary repo path, skipping the public-list
|
|
133
|
+
* requirement entirely.
|
|
134
|
+
*/
|
|
135
|
+
export function classifySource(claim: SourceClaim, facts: SourceProvenanceFacts): SourceClaimResolution {
|
|
136
|
+
const id = claim.id === '' ? '(unnamed claim)' : claim.id;
|
|
137
|
+
const source = typeof claim.source === 'string' && claim.source.trim() !== '' ? claim.source.trim() : null;
|
|
138
|
+
const at = (verdict: SourceVerdict, detail: string): SourceClaimResolution => ({ id, source, verdict, detail });
|
|
139
|
+
|
|
140
|
+
if (source === null) return at('no-source', 'the claim names no source — a claim with no source is exactly what must not go out');
|
|
141
|
+
|
|
142
|
+
if (claim.kind === 'record') {
|
|
143
|
+
return facts.publicRecords.has(source)
|
|
144
|
+
? at('allowed', 'named in the tracked public-records list')
|
|
145
|
+
: at('not-marked-public', 'this record is not in the tracked public list — marking one public must be a reviewable commit, not a field inside an ignored store');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (claim.kind === 'path') {
|
|
149
|
+
const real = facts.resolved.get(source);
|
|
150
|
+
if (real === undefined || real === null) {
|
|
151
|
+
return at('unresolvable', 'the path does not resolve inside the repository — a source that cannot be resolved cannot be shown to be safe');
|
|
152
|
+
}
|
|
153
|
+
if (facts.ignoredPaths === null) {
|
|
154
|
+
// Defensive: `decideSourceProvenance` refuses before reaching here. Kept so a direct caller of
|
|
155
|
+
// classifySource cannot obtain an `allowed` from an oracle that never ran.
|
|
156
|
+
return at('unresolvable', 'the ignore oracle did not run, so no path can be cleared');
|
|
157
|
+
}
|
|
158
|
+
if (facts.ignoredPaths.has(real)) return at('ignored-path', 'git says this path is ignored — the owner\'s own boundary refuses it');
|
|
159
|
+
// Three separate questions, and the first version asked only one. A brand-new file the drafting
|
|
160
|
+
// process wrote is not ignored either, and clearing it would let the generator author its own
|
|
161
|
+
// evidence.
|
|
162
|
+
if (!facts.trackedPaths.has(real)) return at('untracked', 'git does not track this path, so nobody has reviewed it — not being ignored says nothing about a file that has never been committed');
|
|
163
|
+
if (facts.dirtyPaths.has(real)) return at('uncommitted', 'this path has uncommitted changes, so its current contents went through no review');
|
|
164
|
+
return at('allowed', 'committed, reviewed, and not refused by the owner\'s boundary');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return at('unknown-kind', `the manifest declares kind ${JSON.stringify(claim.kind ?? null)} — a kind this gate cannot check is refused, never inferred from the path's shape`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The whole-manifest verdict.
|
|
172
|
+
*
|
|
173
|
+
* Three outcomes, and neither non-pass returns zero. The dangerous one is the third: an empty
|
|
174
|
+
* manifest does not mean "nothing confidential is cited", it means "we checked nothing".
|
|
175
|
+
*/
|
|
176
|
+
export function decideSourceProvenance(manifest: SourceManifest | null, facts: SourceProvenanceFacts): SourceProvenanceDecision {
|
|
177
|
+
if (manifest === null) {
|
|
178
|
+
return { outcome: 'not-established', exit: 3, claims: [], reason: 'the manifest could not be read — nothing was checked, which is not the same as nothing being wrong' };
|
|
179
|
+
}
|
|
180
|
+
if (facts.ignoredPaths === null) {
|
|
181
|
+
// BEFORE classification, and for the WHOLE batch. Measured 2026-08-22: one out-of-repo path in
|
|
182
|
+
// a `git check-ignore --stdin` batch prints the matches found so far, then dies with exit 128 —
|
|
183
|
+
// so crediting what was printed would clear every path queued behind it (ADR-003).
|
|
184
|
+
return { outcome: 'not-established', exit: 3, claims: [], reason: 'the ignore oracle did not run, so not one path was checked — this is not a pass' };
|
|
185
|
+
}
|
|
186
|
+
if (manifest.claims.length === 0) {
|
|
187
|
+
return { outcome: 'not-established', exit: 3, claims: [], reason: 'the manifest lists no claims — a draft with nothing to check has not been shown to be safe, only left unchecked' };
|
|
188
|
+
}
|
|
189
|
+
const claims = manifest.claims.map((c) => classifySource(c, facts));
|
|
190
|
+
const blocked = claims.filter((c) => c.verdict !== 'allowed');
|
|
191
|
+
if (blocked.length > 0) {
|
|
192
|
+
return {
|
|
193
|
+
outcome: 'blocked',
|
|
194
|
+
exit: 1,
|
|
195
|
+
claims,
|
|
196
|
+
reason: `${blocked.length} of ${claims.length} claim(s) cite a source that may not leave this machine`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { outcome: 'allowed', exit: 0, claims, reason: `all ${claims.length} claim(s) cite a source cleared to go out` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What the operator sees. Every blocked claim names its id, its source and its reason. */
|
|
203
|
+
export function renderSourceProvenance(decision: SourceProvenanceDecision): string[] {
|
|
204
|
+
const out: string[] = [];
|
|
205
|
+
const label = decision.outcome === 'allowed' ? 'ALLOWED' : decision.outcome === 'blocked' ? 'BLOCKED' : 'NOT ESTABLISHED';
|
|
206
|
+
for (const c of decision.claims) {
|
|
207
|
+
if (c.verdict === 'allowed') continue;
|
|
208
|
+
out.push(` [${c.verdict}] ${c.id} — ${c.source ?? '(no source)'}: ${c.detail}`);
|
|
209
|
+
}
|
|
210
|
+
out.push(`dz provenance-check: ${label} — ${decision.reason}`);
|
|
211
|
+
if (decision.outcome === 'allowed') {
|
|
212
|
+
// Said on the PASSING path too, because that is the path where it gets forgotten: a green tick
|
|
213
|
+
// here proves where the citations came from, not that the prose is safe.
|
|
214
|
+
out.push(' note: this proves what was CITED. It cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file. Read the draft.');
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
@@ -64,8 +64,8 @@ export function domainMatch(hitDomain: string | null | undefined, wanted: string
|
|
|
64
64
|
return 'none';
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export interface DomainBoostResult {
|
|
68
|
-
readonly hits: readonly
|
|
67
|
+
export interface DomainBoostResult<T extends RecallHit = RecallHit> {
|
|
68
|
+
readonly hits: readonly T[];
|
|
69
69
|
/** How many hits MATCHED the domain (exactly / relatedly). */
|
|
70
70
|
readonly exact: number;
|
|
71
71
|
readonly related: number;
|
|
@@ -88,7 +88,14 @@ export interface DomainBoostResult {
|
|
|
88
88
|
* is deterministic — the same input always yields the same output, which is what
|
|
89
89
|
* makes it testable at all.
|
|
90
90
|
*/
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Generic over the hit type so EXTRA FIELDS SURVIVE the boost. Narrowing to `RecallHit` silently
|
|
93
|
+
* dropped `similarity` from a `HybridHit`, and the closeness a reader is meant to act on vanished
|
|
94
|
+
* the moment `--domain` was passed. Note the deliberate asymmetry with `relevance`, which the CLI
|
|
95
|
+
* nulls under a boost because the boost invalidates the RRF ordering: closeness is
|
|
96
|
+
* ORDER-INDEPENDENT, so it stays populated.
|
|
97
|
+
*/
|
|
98
|
+
export function applyDomainBoost<T extends RecallHit>(hits: readonly T[], wanted: string | null | undefined): DomainBoostResult<T> {
|
|
92
99
|
const target = normalizeDomain(wanted);
|
|
93
100
|
if (target === '' || hits.length === 0) {
|
|
94
101
|
return { hits, exact: 0, related: 0, moved: 0, noMatches: true };
|