@dzhechkov/harness-cli 0.8.10 → 0.8.15

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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * ONE definition of "a dz command", and the ONE enumeration every consumer derives from.
3
+ *
4
+ * WHY (backlog 3b2b05e0, ADR-001 in `features/command-count-triad/03_adr/`): three sources answered
5
+ * "how many dz commands are there" with three numbers — the dispatcher's `case` labels, the rendered
6
+ * `dz --help` list and `dz name-check`'s collision sweep — because each used its own definition, so
7
+ * adding a command meant guessing which number to move and which test would redden.
8
+ *
9
+ * THE DEFINITION (ADR-001 §1): *a dz command is a name that is a `case` label of the main
10
+ * `switch (command)` in `cli.ts` AND has a ` dz <name>` line in USAGE.* `DZ_COMMANDS` is the
11
+ * canonical enumeration of exactly those names. `dispatched` and `documented` are DERIVED from the
12
+ * SAME file text and must each equal `DZ_COMMANDS` as a SET — both directions — modulo two
13
+ * explicit, reasoned exception lists:
14
+ *
15
+ * dispatched == declared ∪ internal (INTERNAL_ENTRY_POINTS — runs, deliberately not in USAGE)
16
+ * documented == declared ∪ pseudo (PSEUDO_COMMANDS — in USAGE, handled pre-dispatch)
17
+ *
18
+ * Everything here is PURE: text in, name sets out. It reads no file and never imports `cli.js` (that
19
+ * would be a cycle — `cli.ts` imports THIS module), so the layer-1 test and `nameCheckScan` share one
20
+ * parser and cannot drift apart by construction (ADR-001 DD3).
21
+ *
22
+ * NAMES, NEVER ONLY COUNTS (ADR-001 DD2): no count literal lives here or in any consumer. A
23
+ * cardinality guard dies to add-one-delete-one — MEASURED live in this repo: the harness-cli README's
24
+ * inventory held 85 names and passed a `size === 85` guard while listing `help` and omitting
25
+ * `verify-pack`.
26
+ */
27
+
28
+ /**
29
+ * One reasoned exemption from the definition. `reason` is mandatory — an exemption without a written
30
+ * reason is the allowlist this feature closed; `since` ages it.
31
+ */
32
+ export interface CommandException {
33
+ readonly name: string;
34
+ readonly reason: string;
35
+ /** `YYYY-MM-DD`. */
36
+ readonly since: string;
37
+ }
38
+
39
+ /** The four name sets derived from one `cli.ts` text, plus the two exception lists in force. */
40
+ export interface CommandInventory {
41
+ /** `DZ_COMMANDS` — the canonical enumeration, parsed from the literal block. */
42
+ readonly declared: readonly string[];
43
+ /** `case` labels of the main `switch (command)` — what `dz <name>` will actually RUN. */
44
+ readonly dispatched: readonly string[];
45
+ /** ` dz <name>` lines in USAGE — what a user can DISCOVER. */
46
+ readonly documented: readonly string[];
47
+ /** Documented but not dispatched ({@link PSEUDO_COMMANDS}). */
48
+ readonly pseudo: readonly string[];
49
+ /** Dispatched but deliberately not documented ({@link INTERNAL_ENTRY_POINTS}). */
50
+ readonly internal: readonly string[];
51
+ }
52
+
53
+ /**
54
+ * Documented in USAGE, handled BEFORE the dispatch switch, therefore not canonical commands. `help`
55
+ * is the only member: it prints USAGE and takes no flags, and adding a `case 'help':` purely so a
56
+ * definition holds would make the definition serve the number (ADR-001 §2, option O1b).
57
+ */
58
+ export const PSEUDO_COMMANDS: readonly CommandException[] = [
59
+ {
60
+ name: 'help',
61
+ reason: 'pre-dispatch built-in: prints USAGE and returns before the main switch (command) — no case label, no flags',
62
+ since: '2026-09-05',
63
+ },
64
+ ];
65
+
66
+ /**
67
+ * Dispatched but deliberately NOT documented — EMPTY today, and that emptiness is the decision, not
68
+ * an oversight (ADR-001 §3, option O2a). The four names that sat in `command-count.test.ts`'s
69
+ * `UNDOCUMENTED_ALLOWLIST` — `mr-rakes`, `retro`, `feature-adr-setup`, `bto-optimize` — got USAGE
70
+ * lines instead of being parked here: discoverability IS existence, and an allowlist entry is a
71
+ * promise to fix later that already cost one field bug report (`project-skills`, 2026-08-25).
72
+ *
73
+ * The list survives as the MECHANISM: a future genuinely internal entry point gets a reasoned home
74
+ * instead of a silent regex exemption. Its members are NOT commands and are NOT in `DZ_COMMANDS`;
75
+ * joining it costs a reason and a date, checked by {@link validateExceptionList}.
76
+ */
77
+ export const INTERNAL_ENTRY_POINTS: readonly CommandException[] = [];
78
+
79
+ const SINCE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
80
+
81
+ /**
82
+ * Refuse an exemption that carries no reason or no usable date, NAMING the offending entry. Without
83
+ * it, "exception list with a reason" decays back into the allowlist it replaced. Proven to FIRE on a
84
+ * synthetic reason-less entry, not merely exercised on the valid real lists (ADR-001 §5 / AM-3) — a
85
+ * validator only ever fed valid input is dead code.
86
+ *
87
+ * @param list the exception list to check
88
+ * @param label how to name the list in the error (e.g. `PSEUDO_COMMANDS`)
89
+ * @throws Error naming the offending entry
90
+ */
91
+ export function validateExceptionList(
92
+ list: readonly CommandException[],
93
+ label = 'command exception list',
94
+ ): readonly CommandException[] {
95
+ list.forEach((entry, index) => {
96
+ const name = typeof entry?.name === 'string' ? entry.name.trim() : '';
97
+ if (name === '') {
98
+ throw new Error(`${label}[${index}] has no name — every exemption names the command it exempts`);
99
+ }
100
+ const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
101
+ if (reason === '') {
102
+ throw new Error(
103
+ `${label}: "${name}" has no reason — an exemption without a written reason is an allowlist, not a decision (ADR-001 §5)`,
104
+ );
105
+ }
106
+ const since = typeof entry.since === 'string' ? entry.since : '';
107
+ if (!SINCE_PATTERN.test(since)) {
108
+ throw new Error(`${label}: "${name}" has since="${since}" — expected a YYYY-MM-DD date so the exemption can be aged`);
109
+ }
110
+ });
111
+ return list;
112
+ }
113
+
114
+ /**
115
+ * Keywords after which a `/` can only open a REGEX literal, never a division: they end a statement or
116
+ * an operator position, so no value precedes the slash. Without them `return /}/.test(x)` would be
117
+ * read as a division and its braces counted (see {@link stripNonCode}).
118
+ */
119
+ const REGEX_PRECEDING_KEYWORDS = new Set([
120
+ 'return', 'case', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'do',
121
+ 'else', 'yield', 'await',
122
+ ]);
123
+
124
+ /**
125
+ * Decide whether a `/` at this point opens a regex literal, given the code emitted so far.
126
+ *
127
+ * THE HEURISTIC, and it IS a heuristic (a real answer needs the TypeScript parser): a slash divides
128
+ * when a VALUE precedes it — an identifier, a number, or a closing `)`/`]`/`}`/quote — and otherwise
129
+ * opens a regex. The one exception is a preceding KEYWORD, which looks like an identifier but leaves
130
+ * an operator position, hence {@link REGEX_PRECEDING_KEYWORDS}.
131
+ *
132
+ * THE LIMIT, named because it is deliberate: after `)`, `]` and `}` this answers "division", so
133
+ * `if (x) /}/.test(y)` and `} /}/.test(y)` still leak their braces. That direction is chosen on
134
+ * ASYMMETRY of damage — a missed regex reproduces today's known under-count, while a division
135
+ * mistaken for a regex would SWALLOW real code and could hide or invent braces anywhere. The
136
+ * same-line bound in {@link stripNonCode} caps what a false positive can eat.
137
+ *
138
+ * COST (QE round 3, `[P2]`): this used to run an END-ANCHORED regex over the whole accumulated
139
+ * output, so every slash rescanned the entire prefix — QUADRATIC in a parser that is public API and
140
+ * runs inside every `dz name-check` (MEASURED pre-fix: 4 000 division-heavy lines took 10 366 ms,
141
+ * 21x longer than the 12x LARGER real `cli.ts`). Both inputs are now O(1): the caller tracks the
142
+ * preceding identifier incrementally, so this function looks at nothing but its two arguments.
143
+ *
144
+ * @param lastIdentifier the identifier ending at `lastSignificant`, or `''` when that is not a word
145
+ * @param lastSignificant the last non-whitespace CODE character emitted, or `''` at the start
146
+ */
147
+ function regexMayStart(lastIdentifier: string, lastSignificant: string): boolean {
148
+ if (lastSignificant === '') return true;
149
+ // The value test. `"` is the MARKER a closed string, template or regex leaves behind — it can
150
+ // never be a real code character, because a literal `"` in code opens a string instead. Omitting
151
+ // it (QE round 4, `[P2]`) made `'4' / d` read as a regex opener: the scanner then ate up to the
152
+ // next slash on the line, usually a trailing `//`, taking the case body's closing brace with it,
153
+ // and every later `case` was dropped. MEASURED pre-fix: two cases lost per body, for all four
154
+ // closers (`'`, `"`, backtick, flagless `/…/`).
155
+ if (!/[\w$)\]}"]/.test(lastSignificant)) return true;
156
+ return REGEX_PRECEDING_KEYWORDS.has(lastIdentifier);
157
+ }
158
+
159
+ /**
160
+ * Replace the CONTENT of every string, template literal, regex and comment with spaces (newlines
161
+ * preserved), leaving only real code, so a `}` inside a string, a regex or a comment in a case body
162
+ * cannot prematurely close the switch. Case LABELS are still matched on the ORIGINAL source. Promoted
163
+ * out of `test/command-count.test.ts` so the CLI and the test share ONE parser.
164
+ *
165
+ * REGEX LITERALS (QE round 1, Codex `gpt-5.6-sol`, `[P2]` on this function): a lone `}` inside `/}/`
166
+ * used to be counted as a real closing brace, so the brace walk in {@link dispatchedCommands} ended
167
+ * on the FIRST case body and the inventory was TRUNCATED — with the parity guard, `dz name-check`
168
+ * and the exported API all under-counting on valid source. Detection is {@link regexMayStart}, whose
169
+ * heuristic and limit are stated there; on top of it this scanner adds a hard SAME-LINE bound: a
170
+ * regex literal cannot contain an unescaped newline, so a candidate whose closing `/` is not on the
171
+ * same line is declared a false positive and the slash is emitted as ordinary code. That bound is
172
+ * what keeps a mis-read division from eating an unbounded span. `[...]` character classes are
173
+ * tracked so `/[/]/` and `/[{}]/` close where they really close.
174
+ */
175
+ export function stripNonCode(src: string): string {
176
+ let out = '';
177
+ let state: 'code' | 'line' | 'block' | "'" | '"' | '`' = 'code';
178
+ /** Last non-whitespace CODE character emitted; a closed string/regex reports as a value (`"`). */
179
+ let lastSignificant = '';
180
+ /**
181
+ * The identifier ending at {@link lastSignificant}, maintained in O(1) so {@link regexMayStart}
182
+ * never rescans the prefix. Whitespace LEAVES it alone (`return /re/` must still see `return`);
183
+ * any other character clears it; a closed string or regex clears it, because it is a value.
184
+ */
185
+ let lastIdentifier = '';
186
+ for (let i = 0; i < src.length; i++) {
187
+ const c = src[i]!; const n = src[i + 1];
188
+ if (state === 'code') {
189
+ if (c === '/' && n === '/') { state = 'line'; out += ' '; i++; continue; }
190
+ if (c === '/' && n === '*') { state = 'block'; out += ' '; i++; continue; }
191
+ if (c === "'" || c === '"' || c === '`') { state = c; out += ' '; continue; }
192
+ if (c === '/' && regexMayStart(lastIdentifier, lastSignificant)) {
193
+ let j = i + 1; let inClass = false; let closed = false;
194
+ for (; j < src.length; j++) {
195
+ const d = src[j]!;
196
+ if (d === '\n') break; // no closing `/` on this line
197
+ if (d === '\\') { if (src[j + 1] === '\n' || j + 1 >= src.length) break; j++; continue; }
198
+ if (inClass) { if (d === ']') inClass = false; continue; }
199
+ if (d === '[') { inClass = true; continue; }
200
+ if (d === '/') { closed = true; break; }
201
+ }
202
+ if (closed) { out += ' '.repeat(j - i + 1); i = j; lastSignificant = '"'; lastIdentifier = ''; continue; }
203
+ // not a regex after all — fall through and emit the slash as ordinary code
204
+ }
205
+ out += c;
206
+ if (/[A-Za-z0-9_$]/.test(c)) { lastSignificant = c; lastIdentifier += c; }
207
+ else if (!/\s/.test(c)) { lastSignificant = c; lastIdentifier = ''; }
208
+ continue;
209
+ }
210
+ if (state === 'line') { if (c === '\n') { state = 'code'; out += '\n'; } else out += ' '; continue; }
211
+ if (state === 'block') { if (c === '*' && n === '/') { state = 'code'; out += ' '; i++; } else out += (c === '\n' ? '\n' : ' '); continue; }
212
+ // inside a string/template: content → spaces, honour escapes, close on the matching quote.
213
+ // An escaped NEWLINE is a LINE CONTINUATION and its newline must SURVIVE (QE round 3, `[P2]`):
214
+ // emitting two spaces here destroyed a line, `stripNonCode` returned fewer lines than it was
215
+ // given, and `dispatchedCommands` — which indexes `codeLines` against `origLines` by the same
216
+ // `i` — then read every later label off the WRONG line and DROPPED real commands.
217
+ if (c === '\\') { out += (src[i + 1] === '\n' ? ' \n' : ' '); i++; continue; }
218
+ if (c === state) { state = 'code'; out += ' '; lastSignificant = '"'; lastIdentifier = ''; continue; }
219
+ out += (c === '\n' ? '\n' : ' ');
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * The names `dz <name>` will actually RUN: `case` labels of the MAIN `switch (command)`, found by
226
+ * brace-aware scanning so extraction is indentation-independent (NFR-6).
227
+ *
228
+ * @throws Error when the main switch is absent — the "guard cannot run" signal: returning `[]` would
229
+ * let every set-equality above it pass at ∅ == ∅.
230
+ */
231
+ export function dispatchedCommands(src: string): string[] {
232
+ const origLines = src.split('\n');
233
+ const codeLines = stripNonCode(src).split('\n'); // braces counted on this; NAME read from origLines
234
+ let start = -1;
235
+ for (let i = 0; i < codeLines.length; i++) { if (/switch \(command\)/.test(codeLines[i]!)) { start = i; break; } }
236
+ if (start < 0) throw new Error('main switch (command) not found — the command inventory cannot be derived from this source');
237
+ const names = new Set<string>();
238
+ let depth = 0; let started = false;
239
+ for (let i = start; i < codeLines.length; i++) {
240
+ // Depth BEFORE this line's own braces are counted. A label belonging DIRECTLY to
241
+ // `switch (command)` sits at depth 1 — inside the switch's braces and nothing else's.
242
+ const depthBefore = depth;
243
+ for (const ch of codeLines[i]!) { if (ch === '{') { depth++; started = true; } else if (ch === '}') { depth--; } }
244
+ // TWO independent conditions, both load-bearing, both from QE round 2:
245
+ // (a) the word `case` must have SURVIVED stripping at the same column — otherwise the line is
246
+ // a comment or a template and its `case 'phantom':` is prose, not a dispatch;
247
+ // (b) the label must sit at switch-body depth — otherwise it belongs to a nested sub-verb
248
+ // switch and `dz <that name>` would never reach it.
249
+ const code = codeLines[i]!.match(/^(\s*)case\b/);
250
+ const orig = origLines[i]!.match(/^(\s*)case ['"]([a-z][a-z0-9-]*)['"]:/); // NAME from ORIGINAL
251
+ const isCode = code !== null && orig !== null && code[1]!.length === orig[1]!.length;
252
+ if (isCode && started && depthBefore === 1) names.add(orig![2]!);
253
+ if (started && depth <= 0 && i > start) break;
254
+ }
255
+ return [...names].sort();
256
+ }
257
+
258
+ /**
259
+ * Cut out the body of the `USAGE` template literal — the ONE block `dz --help` actually renders.
260
+ *
261
+ * WHY it is a function and not a regex (QE round 2, `[P2]`): the naive whole-file scan for
262
+ * ` dz <name>` also swallowed every OTHER help template in `cli.ts`. Scanning stops at the first
263
+ * unescaped backtick outside an `${…}` interpolation, so `${PRESET_NAMES.join(', ')}` on the last
264
+ * USAGE line does not end the literal early.
265
+ *
266
+ * LIMIT, named: a template literal NESTED inside an interpolation would need recursion and is not
267
+ * handled — `cli.ts` has none today, and if one appears this throws rather than guessing.
268
+ *
269
+ * @throws Error when the literal is absent or unterminated — same "cannot run" discipline as
270
+ * {@link dispatchedCommands}: silently returning `''` would let `documented == declared ∪ pseudo`
271
+ * pass at ∅ == ∅.
272
+ */
273
+ export function usageBlock(src: string): string {
274
+ const opener = src.match(/const USAGE\s*(?::[^=]*)?=\s*`/);
275
+ if (opener?.index === undefined) {
276
+ throw new Error('the USAGE template literal was not found — the documented commands cannot be derived from this source');
277
+ }
278
+ const from = opener.index + opener[0].length;
279
+ let interpolation = 0;
280
+ for (let i = from; i < src.length; i++) {
281
+ const c = src[i]!;
282
+ if (c === '\\') { i++; continue; }
283
+ if (c === '$' && src[i + 1] === '{') { interpolation++; i++; continue; }
284
+ if (c === '}' && interpolation > 0) { interpolation--; continue; }
285
+ if (c === '`' && interpolation === 0) return src.slice(from, i);
286
+ }
287
+ throw new Error('the USAGE template literal is unterminated — the documented commands cannot be derived from this source');
288
+ }
289
+
290
+ /**
291
+ * The names a user can DISCOVER: the ` dz <name>` lines of USAGE, read from the SOURCE — the same
292
+ * regex the old count test ran over RENDERED help, deliberately, because the parity test asserts
293
+ * source-set == rendered-set: being right about the text and wrong about what users see is then a
294
+ * FAILURE, not an invisible drift (FR-3.3 / NFR-6).
295
+ *
296
+ * BOUNDED TO USAGE (QE round 2, `[P2]` on the old whole-file regex): a per-command help template
297
+ * such as `BRAIN_USAGE` (`cli.ts:5019+`) carries a dozen ` dz brain …` lines that global
298
+ * `dz --help` never renders. They were being counted; they deduped onto the `brain` already in
299
+ * USAGE, so the leak was invisible BY LUCK, and one unique name in such a block would have invented
300
+ * a documented command for both `dz name-check` and the parity guard.
301
+ */
302
+ export function documentedCommands(src: string): string[] {
303
+ const names = new Set<string>();
304
+ for (const m of usageBlock(src).matchAll(/^ {2}dz {1,}([a-z][a-z0-9-]*)/gm)) names.add(m[1]!);
305
+ return [...names].sort();
306
+ }
307
+
308
+ /**
309
+ * The canonical enumeration: the names inside the `DZ_COMMANDS` literal block, parsed from TEXT so
310
+ * all four sets come from ONE artefact. The runtime export is separately asserted equal to this,
311
+ * which is what catches a parser that drifts from the literal it reads.
312
+ *
313
+ * @throws Error when the block is absent — same "cannot run" discipline as {@link dispatchedCommands}.
314
+ */
315
+ export function declaredCommands(src: string): string[] {
316
+ const block = src.match(/export const DZ_COMMANDS: readonly string\[\] = \[([\s\S]*?)\];/);
317
+ if (block?.[1] === undefined) {
318
+ throw new Error('the DZ_COMMANDS literal block was not found — the canonical enumeration cannot be derived from this source');
319
+ }
320
+ const names = new Set<string>();
321
+ for (const m of block[1].matchAll(/'([a-z][a-z0-9-]*)'/g)) names.add(m[1]!);
322
+ return [...names].sort();
323
+ }
324
+
325
+ /**
326
+ * All four name sets from ONE `cli.ts` text, with both exception lists validated FIRST — deliberately
327
+ * up front, so a real entry with an empty reason breaks the whole inventory rather than one
328
+ * assertion, and the exemption channel cannot rot quietly (ADR-001 §5).
329
+ *
330
+ * @param src the full text of `packages/@dzhechkov/harness-cli/src/cli.ts`
331
+ */
332
+ export function commandInventory(src: string): CommandInventory {
333
+ validateExceptionList(PSEUDO_COMMANDS, 'PSEUDO_COMMANDS');
334
+ validateExceptionList(INTERNAL_ENTRY_POINTS, 'INTERNAL_ENTRY_POINTS');
335
+ return {
336
+ declared: declaredCommands(src),
337
+ dispatched: dispatchedCommands(src),
338
+ documented: documentedCommands(src),
339
+ pseudo: [...PSEUDO_COMMANDS.map((e) => e.name)].sort(),
340
+ internal: [...INTERNAL_ENTRY_POINTS.map((e) => e.name)].sort(),
341
+ };
342
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@ export const HARNESS_CLI_VERSION: string =
11
11
  (createRequire(import.meta.url)('../package.json') as { version: string }).version;
12
12
 
13
13
  export { DZ_COMMANDS, runCli } from './cli.js';
14
+ // ONE definition of "a dz command" (ADR-001, feature command-count-triad). Exported so every
15
+ // consumer — the layer-1 parity test, `dz name-check`, any future doc generator — derives the four
16
+ // name sets from the same pure parser instead of growing a fourth private regex and a fourth number.
17
+ export {
18
+ commandInventory, declaredCommands, dispatchedCommands, documentedCommands, stripNonCode,
19
+ validateExceptionList, INTERNAL_ENTRY_POINTS, PSEUDO_COMMANDS,
20
+ } from './command-inventory.js';
21
+ export type { CommandException, CommandInventory } from './command-inventory.js';
14
22
  export type { CliIo, ReleaseExecRunner } from './cli.js';
15
23
  // The Codex hook DELIVERY seam (crossrt-2 fix round, findings 1+2): the argv→operation mapping and
16
24
  // the one place a success word may be printed, exported so both can be pinned without a live codex.
@@ -12,6 +12,7 @@
12
12
  export const KNOWN_CLI_FLAGS: readonly string[] = [
13
13
  'affected',
14
14
  'all',
15
+ 'allow-cold-start',
15
16
  'allow-integrations',
16
17
  'allow-same-family',
17
18
  'allow-same-family-qe',
@@ -199,6 +200,7 @@ export const KNOWN_CLI_FLAGS: readonly string[] = [
199
200
  'plan',
200
201
  'plugin-dir',
201
202
  'porcelain',
203
+ 'prefix',
202
204
  'preset',
203
205
  'pretty',
204
206
  'preview',
@@ -221,6 +223,7 @@ export const KNOWN_CLI_FLAGS: readonly string[] = [
221
223
  'reinforce',
222
224
  'remove',
223
225
  'report',
226
+ 'reset',
224
227
  'require-plan',
225
228
  'require-signing',
226
229
  'rerank',
@@ -237,6 +240,7 @@ export const KNOWN_CLI_FLAGS: readonly string[] = [
237
240
  'safe-mode',
238
241
  'sandbox',
239
242
  'save-dev',
243
+ 'scan-tail',
240
244
  'scenarios',
241
245
  'scope-check',
242
246
  'score',
@@ -285,6 +289,7 @@ export const KNOWN_CLI_FLAGS: readonly string[] = [
285
289
  'tolerance',
286
290
  'tools',
287
291
  'topics',
292
+ 'transcript',
288
293
  'type',
289
294
  'usage',
290
295
  'validate',