@orkestrel/scaffold 0.0.108 → 0.0.109

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.
Files changed (157) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
  7. package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
  8. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
  9. package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
  10. package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
  11. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
  12. package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
  13. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
  14. package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
  15. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
  16. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
  17. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
  18. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
  19. package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
  20. package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
  21. package/dist/agents/templates/brief.md +1 -1
  22. package/dist/bin/main.js +51 -46
  23. package/dist/bin/main.js.map +1 -1
  24. package/dist/host/AGENTS.md +23 -21
  25. package/dist/host/agents/orchestration.md +43 -18
  26. package/dist/host/agents/platforms/windows.md +3 -3
  27. package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
  28. package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
  29. package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
  30. package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
  31. package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
  32. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
  33. package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
  34. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
  35. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
  36. package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
  37. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
  38. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
  39. package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
  40. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
  41. package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
  42. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
  43. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
  44. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
  45. package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
  46. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
  47. package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
  48. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
  49. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
  50. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  51. package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
  52. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
  53. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
  54. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
  55. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
  56. package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
  57. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
  58. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
  59. package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
  60. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
  61. package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
  62. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
  63. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
  64. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
  65. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
  66. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
  67. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
  68. package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
  69. package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
  70. package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
  71. package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
  72. package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
  73. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
  74. package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
  75. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
  76. package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
  77. package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
  78. package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
  79. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
  80. package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
  81. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
  82. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
  83. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
  84. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
  85. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
  86. package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
  87. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
  88. package/dist/host/agents/templates/brief.md +1 -1
  89. package/dist/host/agents/transports/claude-cli.md +3 -3
  90. package/dist/host/agents/transports/codex.md +2 -7
  91. package/dist/host/agents/transports/cursor.md +2 -3
  92. package/dist/host/claude/AGENTS.md +2 -2
  93. package/dist/host/claude/agents/analyst.md +3 -3
  94. package/dist/host/claude/agents/astra.md +3 -3
  95. package/dist/host/claude/agents/builder.md +1 -1
  96. package/dist/host/claude/agents/checker.md +1 -1
  97. package/dist/host/claude/agents/distiller.md +1 -1
  98. package/dist/host/claude/agents/grok.md +1 -1
  99. package/dist/host/claude/agents/opus.md +2 -2
  100. package/dist/host/claude/agents/orkestrel.md +57 -90
  101. package/dist/host/claude/agents/researcher.md +1 -1
  102. package/dist/host/claude/agents/reviewer.md +4 -0
  103. package/dist/host/claude/agents/scout.md +1 -1
  104. package/dist/host/claude/agents/verifier.md +1 -1
  105. package/dist/host/claude/rules/application.md +14 -21
  106. package/dist/host/claude/rules/architecture.md +34 -44
  107. package/dist/host/claude/rules/browser.md +3 -4
  108. package/dist/host/claude/rules/campaign.md +2 -2
  109. package/dist/host/claude/rules/documentation.md +25 -12
  110. package/dist/host/claude/rules/names.md +9 -4
  111. package/dist/host/claude/rules/patterns.md +6 -7
  112. package/dist/host/claude/rules/portability.md +1 -1
  113. package/dist/host/claude/rules/quality.md +4 -5
  114. package/dist/host/claude/rules/styles.md +2 -2
  115. package/dist/host/claude/rules/tests.md +57 -114
  116. package/dist/host/claude/rules/typescript.md +7 -9
  117. package/dist/host/claude/rules/workspace.md +21 -19
  118. package/dist/host/claude/rules/writing.md +2 -5
  119. package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
  120. package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
  121. package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
  122. package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
  123. package/dist/host/codex/agents/analyst.toml +9 -3
  124. package/dist/host/codex/agents/astra.toml +3 -4
  125. package/dist/host/codex/agents/builder.toml +1 -1
  126. package/dist/host/codex/agents/opus.toml +3 -4
  127. package/dist/host/codex/agents/orkestrel.toml +1 -1
  128. package/dist/host/codex/agents/researcher.toml +2 -2
  129. package/dist/host/codex/agents/verifier.toml +1 -1
  130. package/dist/host/configs/helpers.ts +21 -1
  131. package/dist/host/configs/policy.ts +299 -15
  132. package/dist/host/dotfiles/oxlintrc.json +137 -3
  133. package/dist/host/guides/agent.md +111 -134
  134. package/dist/host/guides/capture.md +801 -0
  135. package/dist/host/guides/probe.md +27 -8
  136. package/dist/host/guides/scaffold.md +96 -18
  137. package/dist/host/guides/study.md +534 -440
  138. package/dist/host/guides/test.md +111 -44
  139. package/dist/host/manifest.json +160 -240
  140. package/dist/host/tests/config.test.ts +971 -187
  141. package/dist/host/tests/policy.test.ts +127 -1
  142. package/dist/host/tests/setupPolicy.ts +893 -33
  143. package/dist/src/core/index.cjs +214 -86
  144. package/dist/src/core/index.cjs.map +1 -1
  145. package/dist/src/core/index.d.cts +122 -38
  146. package/dist/src/core/index.d.ts +122 -38
  147. package/dist/src/core/index.js +209 -85
  148. package/dist/src/core/index.js.map +1 -1
  149. package/dist/src/server/index.cjs +20 -27
  150. package/dist/src/server/index.cjs.map +1 -1
  151. package/dist/src/server/index.js +21 -28
  152. package/dist/src/server/index.js.map +1 -1
  153. package/package.json +11 -11
  154. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
  155. package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
  156. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
  157. package/dist/host/guides/supervisor.md +0 -4890
@@ -1,27 +1,169 @@
1
1
  // Run from the checkout root:
2
2
  // node .agents/skills/orkestrel-harden/scripts/titles.ts --base REV [--root DIR] [--json] [--out FILE]
3
- // Compare exact decoded describe/it/test title counts in tests/**/*.test.ts against REV. Report
4
- // deficits with their baseline files, or as moved when another tests/**/*.ts string literal holds
5
- // the title. Confirm moved registrations by reading their holder. Decode quote and backslash
6
- // escapes only. Balance each-table parentheses over code tokens, skipping lexical regions.
7
- // Print text by default or one JSON line with --json; --out writes .json or .txt independently.
8
- // Compare interpolated templates by full raw spelling. Refuse unclosed lexical tokens.
3
+ // Each value is a separate argument: `--flag=value` and any flag the script does not read exit 64,
4
+ // and so does a REV that opens with `-`. The baseline is read from the tree REV names, resolved
5
+ // once with `--end-of-options`, with paths relative to --root, so a --root below the repository top
6
+ // works.
7
+ // Compare exact decoded describe/it/test/suite title counts in tests/**/*.test.ts against REV. A
8
+ // baseline title with a deficit is moved when another tests/**/*.ts string literal spells it, and
9
+ // missing otherwise. A moved row is a claim to rule, not proof: a literal that only spells the
10
+ // title (an assertion value, a data table) also holds it, so the reader rules each moved row by
11
+ // reading its holder, as it rules each missing row. Read a registration at any token position, with
12
+ // its modifiers: `.each` tables (call or tagged template) and `.skipIf(...)` and `.runIf(...)` read
13
+ // the title of the call that follows. A title that is no string or template literal is a dynamic
14
+ // row, named per tree with its source spelling: the comparison cannot follow it, and a removed
15
+ // `.each` table row leaves its one registered title in place. A file with fewer dynamic
16
+ // registrations than at REV is a lost-dynamic row, which the comparison cannot rule on, so it
17
+ // exits 3 as a missing row does. A title registered through an alias (a `test.extend` result bound
18
+ // to another name, or `const spec = it`) is not read, and a `test.extend({...})` call itself reads
19
+ // as one dynamic registration. Decode quote and backslash escapes only.
20
+ // Balance each-table parentheses over code tokens, skipping lexical regions. Print text by default
21
+ // or one JSON line with --json; --out writes .json or .txt independently. Text rows escape tab and
22
+ // newline. Compare interpolated templates by full raw spelling. Refuse unclosed lexical tokens.
9
23
  // Track lexical brace context without a syntax tree. Function-expression and arrow bodies cannot
10
24
  // establish a following slash's grammar; refuse that detectable ambiguity instead of claiming a move.
11
- // Exit 0 with no missing titles, 3 with missing titles, 2 on git, filesystem or token refusal, 64 on usage.
25
+ // Only .test.ts files register titles; a .test.tsx or .test.mts file is invisible to it.
26
+ // Exit 0 with no missing or moved titles and no lost-dynamic file, 3 with any, 2 on git, filesystem
27
+ // or token refusal, 64 on usage.
12
28
  import { spawnSync } from 'node:child_process';
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
14
30
  import { dirname, extname, join, relative, resolve } from 'node:path';
15
- import { listFiles, normalizeSlashes, readMissingFlags, readOption, } from "../../orkestrel-dispatch/scripts/helpers.js";
16
- const TITLE_PATTERN = /^\s*(?:it|test|describe)(?:\.\w+)*\(/gmu;
17
- const REGEX_PREFIX = /^(?:return|throw|case|delete|void|typeof|instanceof|in|of|yield|await|else|do)$/u;
18
- function decodeTitle(text, token) {
31
+ /** Lists the flags this script reads, value flags and switches alike. */
32
+ export const FLAGS = ['--base', '--root', '--json', '--out'];
33
+ /** Holds the usage line printed on a usage refusal. */
34
+ export const USAGE = 'usage: titles.ts --base REV [--root DIR] [--json] [--out FILE.json|FILE.txt]';
35
+ /** Caps the characters of a name an error message echoes. */
36
+ export const NAME_LIMIT = 80;
37
+ /** Names the directories a tree walk skips, so build output and notes stay unread. */
38
+ export const SKIPPED_DIRECTORIES = new Set([
39
+ 'node_modules',
40
+ '.git',
41
+ 'dist',
42
+ 'tmp',
43
+ '.orkestrel',
44
+ '.idea',
45
+ ]);
46
+ /** Matches the keywords after which a slash opens a regular expression. */
47
+ export const REGEX_PREFIX = /^(?:return|throw|case|delete|void|typeof|instanceof|in|of|yield|await|else|do)$/u;
48
+ /** Matches an identifier as one word. */
49
+ export const IDENTIFIER_PATTERN = /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u;
50
+ /** Names the identifiers that open a test registration. */
51
+ export const REGISTRARS = new Set(['it', 'test', 'describe', 'suite']);
52
+ /** Names the calls whose argument list or table precedes the registration call. */
53
+ export const ARGUMENT_MODIFIERS = new Set(['each', 'skipIf', 'runIf', 'for']);
54
+ /** Lists the tokens that open a bracket group. */
55
+ export const OPENERS = ['(', '[', '{'];
56
+ /** Lists the tokens that close a bracket group. */
57
+ export const CLOSERS = [')', ']', '}'];
58
+ /**
59
+ * Normalizes a host path to forward slashes.
60
+ *
61
+ * @param path - A host path.
62
+ * @returns The same path with every backslash replaced by a slash.
63
+ */
64
+ export function normalizeSlashes(path) {
65
+ return path.split('\\').join('/');
66
+ }
67
+ /**
68
+ * Reads the value that follows a flag.
69
+ *
70
+ * @remarks
71
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
72
+ * and the `--flag=value` form is not read.
73
+ *
74
+ * @param argv - The arguments after the script path.
75
+ * @param name - The flag, such as `--base`.
76
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
77
+ * another flag.
78
+ */
79
+ export function readOption(argv, name) {
80
+ const index = argv.indexOf(name);
81
+ const value = index === -1 ? undefined : argv[index + 1];
82
+ return value === undefined || value.startsWith('--') ? undefined : value;
83
+ }
84
+ /**
85
+ * Names the value flags a caller gave with no value.
86
+ *
87
+ * @param argv - The arguments after the script path.
88
+ * @param names - The flags that take a value.
89
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
90
+ */
91
+ export function readMissingFlags(argv, names) {
92
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined);
93
+ }
94
+ /**
95
+ * Names the flags a caller gave that the script does not read.
96
+ *
97
+ * @remarks
98
+ * Reads the arguments before a bare `--`. A `--flag=value` argument is unknown, because the script
99
+ * reads `--flag value` pairs only.
100
+ *
101
+ * @param argv - The arguments after the script path.
102
+ * @param flags - Every flag the script reads, value flags and switches alike.
103
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
104
+ */
105
+ export function readUnknownFlags(argv, flags) {
106
+ const end = argv.indexOf('--');
107
+ return (end === -1 ? argv : argv.slice(0, end)).filter((argument) => argument.startsWith('--') && !flags.includes(argument));
108
+ }
109
+ /**
110
+ * Lists every regular file beneath a directory, skipping the named directories at any depth.
111
+ *
112
+ * @param directory - The directory to walk.
113
+ * @param skipped - The directory names to skip. Default: {@link SKIPPED_DIRECTORIES}.
114
+ * @returns Each file's forward-slash path and modification time, in walk order.
115
+ * @throws Thrown when `directory` does not exist or cannot be read; check `existsSync` first.
116
+ */
117
+ export function listFiles(directory, skipped = SKIPPED_DIRECTORIES) {
118
+ const files = [];
119
+ const pending = [directory];
120
+ while (pending.length > 0) {
121
+ const current = pending.pop();
122
+ if (current === undefined)
123
+ break;
124
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
125
+ if (skipped.has(entry.name))
126
+ continue;
127
+ const path = join(current, entry.name);
128
+ if (entry.isDirectory())
129
+ pending.push(path);
130
+ else if (entry.isFile()) {
131
+ files.push({ path: normalizeSlashes(path), modified: statSync(path).mtimeMs });
132
+ }
133
+ }
134
+ }
135
+ return files;
136
+ }
137
+ /**
138
+ * Describes a name for an error message, JSON-encoded and length-capped.
139
+ *
140
+ * @param name - The untrusted name to echo.
141
+ * @returns The encoded name, cut where it exceeds the cap.
142
+ */
143
+ export function describeName(name) {
144
+ return JSON.stringify(name.length > NAME_LIMIT ? `${name.slice(0, NAME_LIMIT)}…` : name);
145
+ }
146
+ /**
147
+ * Decodes the title a string or template token spells.
148
+ *
149
+ * @param text - The source text.
150
+ * @param token - A string or template token.
151
+ * @returns The raw spelling of an interpolated template, otherwise the text with quote and
152
+ * backslash escapes decoded.
153
+ */
154
+ export function decodeTitle(text, token) {
19
155
  const raw = text.slice(token.start + 1, token.end - 1);
20
156
  return token.category === 'template' && token.children.length > 0
21
157
  ? raw
22
158
  : raw.replace(/\\(['"`\\])/gu, '$1');
23
159
  }
24
- class TokenScanner {
160
+ /**
161
+ * Scans JavaScript or TypeScript source into lexical tokens without a syntax tree.
162
+ *
163
+ * @remarks
164
+ * Keeps its position and its brace and parenthesis context in `#` fields, one scanner per scan.
165
+ */
166
+ export class TokenScanner {
25
167
  #text;
26
168
  #file;
27
169
  #parentheses = [];
@@ -159,14 +301,14 @@ class TokenScanner {
159
301
  return { category: 'template', start, end: this.#position, children };
160
302
  }
161
303
  else if (this.#text.startsWith('${', this.#position)) {
162
- const opening = this.#position;
304
+ const offset = this.#position;
163
305
  this.#position += 2;
164
306
  this.#braces.push({ category: 'template', ambiguous: false });
165
307
  const expression = this.#readCode(true, depth);
166
308
  this.#braces.pop();
167
309
  children.push({
168
310
  category: 'code',
169
- start: opening,
311
+ start: offset,
170
312
  end: this.#position,
171
313
  children: expression,
172
314
  });
@@ -178,62 +320,240 @@ class TokenScanner {
178
320
  throw new Error(`${this.#file}:${start}: Unclosed template token`);
179
321
  }
180
322
  }
181
- function readArguments(text, tokens, location, depth) {
182
- if (depth > 256)
183
- throw new Error(`${location}: each argument nesting exceeds 256`);
184
- for (const token of tokens) {
185
- if (token.category !== 'code')
186
- continue;
187
- const spelling = text.slice(token.start, token.end);
188
- if (spelling === '(')
189
- readArguments(text, tokens, location, depth + 1);
190
- else if (spelling === ')')
191
- return token.end;
323
+ /**
324
+ * Reads the source text a token spans.
325
+ *
326
+ * @param text - The source text.
327
+ * @param token - The token.
328
+ * @returns The token's spelling.
329
+ */
330
+ export function spell(text, token) {
331
+ return text.slice(token.start, token.end);
332
+ }
333
+ /**
334
+ * Checks whether a token is a comment or whitespace.
335
+ *
336
+ * @param text - The source text.
337
+ * @param token - The token.
338
+ * @returns True if the token carries no code.
339
+ */
340
+ export function isTrivia(text, token) {
341
+ return (token.category === 'line' ||
342
+ token.category === 'block' ||
343
+ (token.category === 'code' && spell(text, token).trim() === ''));
344
+ }
345
+ /**
346
+ * Reads the line number of a token's start.
347
+ *
348
+ * @param text - The source text.
349
+ * @param token - The token, or undefined for the start of the text.
350
+ * @returns The one-based line number.
351
+ */
352
+ export function readLine(text, token) {
353
+ return text.slice(0, token?.start ?? 0).split(/\r\n|\n/u).length;
354
+ }
355
+ /**
356
+ * Finds the parenthesis that closes a modifier's argument group.
357
+ *
358
+ * @param text - The source text.
359
+ * @param code - The code tokens, without comments and whitespace.
360
+ * @param open - The index of the opening parenthesis.
361
+ * @param name - The modifier name, for the refusal.
362
+ * @param file - The file label, for the refusal.
363
+ * @returns The index of the matching closing parenthesis.
364
+ * @throws Thrown when the group never closes.
365
+ */
366
+ export function findGroupEnd(text, code, open, name, file) {
367
+ const found = code.slice(open).reduce((state, token, offset) => {
368
+ if (state.end >= 0 || token.category !== 'code')
369
+ return state;
370
+ const word = spell(text, token);
371
+ const depth = state.depth + (word === '(' ? 1 : word === ')' ? -1 : 0);
372
+ return { depth, end: word === ')' && depth === 0 ? open + offset : -1 };
373
+ }, { depth: 0, end: -1 });
374
+ if (found.end < 0) {
375
+ throw new Error(`${file}:${readLine(text, code[open])}: Unbalanced ${name} arguments`);
192
376
  }
193
- throw new Error(`${location}: Unbalanced each arguments`);
377
+ return found.end;
194
378
  }
195
- function readRegistrations(text, tokens, file) {
196
- const code = tokens
197
- .map((token) => {
198
- const raw = text.slice(token.start, token.end);
199
- if (token.category === 'code')
200
- return raw;
201
- const blank = raw
202
- .split('')
203
- .map((character) => (/[\r\n]/u.test(character) ? character : ' '))
204
- .join('');
205
- return token.category === 'string' || token.category === 'template'
206
- ? `\0${blank.slice(1)}`
207
- : blank;
208
- })
209
- .join('');
210
- const offset = tokens[0]?.start ?? 0;
211
- const positions = new Map(tokens.map((token, index) => [token.start, index]));
212
- const starts = new Set(Array.from(code.matchAll(TITLE_PATTERN)).flatMap((match) => {
213
- const opening = offset + match.index + match[0].length - 1;
214
- const direct = /^\s*\0/u.exec(code.slice(opening + 1 - offset));
215
- if (!/\.each\(/u.test(match[0]) && direct !== null)
216
- return [opening + direct[0].length];
217
- const index = positions.get(opening);
218
- if (index === undefined)
219
- return [];
220
- const line = text.slice(0, opening).split(/\r\n|\n/u).length;
221
- const end = readArguments(text, tokens.slice(index + 1).values(), `${file}:${line}`, 0);
222
- const title = /^\s*\(\s*\0/u.exec(code.slice(end - offset));
223
- return title === null ? [] : [end + title[0].length - 1];
224
- }));
379
+ /**
380
+ * Finds where a registration call's first argument ends.
381
+ *
382
+ * @param text - The source text.
383
+ * @param code - The code tokens, without comments and whitespace.
384
+ * @param from - The index of the first argument's first token.
385
+ * @param file - The file label, for the refusal.
386
+ * @returns The index of the first top-level comma or closing parenthesis.
387
+ * @throws Thrown when the call never closes.
388
+ */
389
+ export function findArgumentEnd(text, code, from, file) {
390
+ const found = code.slice(from).reduce((state, token, offset) => {
391
+ if (state.end >= 0 || token.category !== 'code')
392
+ return state;
393
+ const word = spell(text, token);
394
+ if (state.depth === 0 && (word === ',' || word === ')')) {
395
+ return { depth: 0, end: from + offset };
396
+ }
397
+ return {
398
+ depth: state.depth + (OPENERS.includes(word) ? 1 : CLOSERS.includes(word) ? -1 : 0),
399
+ end: -1,
400
+ };
401
+ }, { depth: 0, end: -1 });
402
+ if (found.end < 0) {
403
+ throw new Error(`${file}:${readLine(text, code[from - 1])}: Unbalanced call arguments`);
404
+ }
405
+ return found.end;
406
+ }
407
+ /**
408
+ * Reads the modifier chain after a registrar, up to its registration call.
409
+ *
410
+ * @remarks
411
+ * `.each` and its tagged-template form, `.skipIf(...)`, `.runIf(...)`, and `.for(...)` are followed
412
+ * by a second call that registers the title, so the first group or template is skipped.
413
+ *
414
+ * @param text - The source text.
415
+ * @param code - The code tokens, without comments and whitespace.
416
+ * @param at - The index after the registrar or the last chain element.
417
+ * @param last - The last modifier name read; empty after a skipped group or at the start.
418
+ * @param file - The file label, for a refusal.
419
+ * @returns The index of the registration call's opening parenthesis, or -1 when the tokens are no
420
+ * registration call.
421
+ */
422
+ export function readCall(text, code, at, last, file) {
423
+ const token = code[at];
424
+ if (token === undefined)
425
+ return -1;
426
+ const word = token.category === 'code' ? spell(text, token) : '';
427
+ if (word === '.') {
428
+ const name = code[at + 1];
429
+ const spelled = name?.category === 'code' ? spell(text, name) : '';
430
+ return IDENTIFIER_PATTERN.test(spelled) ? readCall(text, code, at + 2, spelled, file) : -1;
431
+ }
432
+ if (token.category === 'template' && ARGUMENT_MODIFIERS.has(last)) {
433
+ return readCall(text, code, at + 1, '', file);
434
+ }
435
+ if (word !== '(')
436
+ return -1;
437
+ if (!ARGUMENT_MODIFIERS.has(last))
438
+ return at;
439
+ return readCall(text, code, findGroupEnd(text, code, at, last, file) + 1, '', file);
440
+ }
441
+ /**
442
+ * Checks whether the code token at an index opens a test registration.
443
+ *
444
+ * @param text - The source text.
445
+ * @param code - The code tokens, without comments and whitespace.
446
+ * @param index - The token index.
447
+ * @returns True if the token is a registrar word that no member access precedes.
448
+ */
449
+ export function isRegistrar(text, code, index) {
450
+ const token = code[index];
451
+ if (token?.category !== 'code' || !REGISTRARS.has(spell(text, token)))
452
+ return false;
453
+ const previous = code[index - 1];
454
+ return !(previous?.category === 'code' && spell(text, previous) === '.');
455
+ }
456
+ /**
457
+ * Reads the registration a registrar token starts.
458
+ *
459
+ * @param text - The source text.
460
+ * @param code - The code tokens, without comments and whitespace.
461
+ * @param index - The registrar's index.
462
+ * @param file - The file label, for a refusal.
463
+ * @returns One registration, or none when the registrar starts no call.
464
+ */
465
+ export function readRegistration(text, code, index, file) {
466
+ const call = readCall(text, code, index + 1, '', file);
467
+ if (call < 0)
468
+ return [];
469
+ const first = code.slice(call + 1, findArgumentEnd(text, code, call + 1, file));
470
+ const only = first.length === 1 ? first[0] : undefined;
471
+ if (only !== undefined && (only.category === 'string' || only.category === 'template')) {
472
+ return [{ token: only, spelling: spell(text, only) }];
473
+ }
474
+ const head = first[0];
475
+ const tail = first.at(-1);
225
476
  return [
226
- ...tokens.filter((token) => starts.has(token.start)),
227
- ...tokens.flatMap((token) => readRegistrations(text, token.children, file)),
477
+ {
478
+ token: undefined,
479
+ spelling: head === undefined || tail === undefined
480
+ ? ''
481
+ : text.slice(head.start, tail.end).replace(/\s+/gu, ' '),
482
+ },
228
483
  ];
229
484
  }
230
- function collectLiterals(tokens) {
485
+ /**
486
+ * Reads every test registration in a token list and in the template expressions nested in it.
487
+ *
488
+ * @param text - The source text.
489
+ * @param tokens - The scanned tokens.
490
+ * @param file - The file label, for a refusal.
491
+ * @returns The registrations in token order, then those nested in template expressions.
492
+ */
493
+ export function readRegistrations(text, tokens, file) {
494
+ const code = tokens.filter((token) => !isTrivia(text, token));
495
+ const here = code.flatMap((_token, index) => isRegistrar(text, code, index) ? readRegistration(text, code, index, file) : []);
496
+ return [...here, ...tokens.flatMap((token) => readRegistrations(text, token.children, file))];
497
+ }
498
+ /**
499
+ * Collects the string and template tokens of a token tree.
500
+ *
501
+ * @param tokens - The scanned tokens.
502
+ * @returns Every string or template token, nested ones included.
503
+ */
504
+ export function collectLiterals(tokens) {
231
505
  return tokens.flatMap((token) => [
232
506
  ...(token.category === 'string' || token.category === 'template' ? [token] : []),
233
507
  ...collectLiterals(token.children),
234
508
  ]);
235
509
  }
236
- function readGit(root, args) {
510
+ /**
511
+ * Lists the registrations of a tree that have no literal title.
512
+ *
513
+ * @param file - The file the registrations came from.
514
+ * @param registrations - The file's registrations.
515
+ * @param tree - The tree the file was read from.
516
+ * @returns One dynamic row per registration without a title token.
517
+ */
518
+ export function readDynamic(file, registrations, tree) {
519
+ return registrations
520
+ .filter((registration) => registration.token === undefined)
521
+ .map((registration) => ({ file, spelling: registration.spelling, tree }));
522
+ }
523
+ /**
524
+ * Counts the registrations that have no literal title.
525
+ *
526
+ * @param registrations - A file's registrations.
527
+ * @returns How many have no title token.
528
+ */
529
+ export function countDynamic(registrations) {
530
+ return registrations.filter((registration) => registration.token === undefined).length;
531
+ }
532
+ /**
533
+ * Lists the files that register fewer non-literal titles than the baseline did.
534
+ *
535
+ * @param baseline - Each baseline test file with its registrations.
536
+ * @param current - Each working test file with its registrations.
537
+ * @returns One row per baseline file whose count of non-literal registrations dropped, with the
538
+ * drop; a file absent from the working tree counts none.
539
+ */
540
+ export function readLostDynamic(baseline, current) {
541
+ return baseline.flatMap(({ file, registrations }) => {
542
+ const before = countDynamic(registrations);
543
+ const now = current.find((entry) => entry.file === file);
544
+ const after = now === undefined ? 0 : countDynamic(now.registrations);
545
+ return before > after ? [{ file, deficit: before - after }] : [];
546
+ });
547
+ }
548
+ /**
549
+ * Runs git in a directory and returns its output.
550
+ *
551
+ * @param root - The directory to run in.
552
+ * @param args - The git arguments.
553
+ * @returns The standard output.
554
+ * @throws Thrown when git cannot run or exits nonzero.
555
+ */
556
+ export function readGit(root, args) {
237
557
  const result = spawnSync('git', ['-C', root, ...args], {
238
558
  encoding: 'utf8',
239
559
  windowsHide: true,
@@ -244,17 +564,29 @@ function readGit(root, args) {
244
564
  }
245
565
  return result.stdout;
246
566
  }
247
- function readReport(base, root) {
248
- const baseline = readGit(root, ['ls-tree', '-r', '-z', '--name-only', base, '--', 'tests'])
567
+ /**
568
+ * Compares the titles of the working tree with the baseline revision.
569
+ *
570
+ * @param base - The revision to compare against.
571
+ * @param root - The directory whose `tests/` tree is compared; it may sit below the repository top.
572
+ * @returns The missing, moved, and dynamic rows with the baseline title count.
573
+ * @throws Thrown when git refuses, a file cannot be read, or a token cannot be classified.
574
+ */
575
+ export function readReport(base, root) {
576
+ const tree = readGit(root, ['rev-parse', '--verify', '--end-of-options', `${base}^{tree}`]).trim();
577
+ const baselineFiles = readGit(root, ['ls-tree', '-r', '-z', '--name-only', tree, '--', 'tests'])
249
578
  .split('\0')
250
579
  .filter((file) => file.endsWith('.test.ts'))
251
- .flatMap((file) => {
252
- const text = readGit(root, ['show', `${base}:${file}`]);
253
- return readRegistrations(text, new TokenScanner(text, `${base}:${file}`).scan(), `${base}:${file}`).map((token) => ({
580
+ .map((file) => {
581
+ const text = readGit(root, ['show', `${tree}:./${file}`]);
582
+ const label = `${base}:${file}`;
583
+ return {
254
584
  file,
255
- title: decodeTitle(text, token),
256
- }));
585
+ text,
586
+ registrations: readRegistrations(text, new TokenScanner(text, label).scan(), label),
587
+ };
257
588
  });
589
+ const baseline = baselineFiles.flatMap(({ file, text, registrations }) => registrations.flatMap(({ token }) => token === undefined ? [] : [{ file, title: decodeTitle(text, token) }]));
258
590
  const directory = join(root, 'tests');
259
591
  const files = (existsSync(directory) ? listFiles(directory) : [])
260
592
  .filter(({ path }) => path.endsWith('.ts'))
@@ -272,11 +604,14 @@ function readReport(base, root) {
272
604
  .sort((left, right) => left.file.localeCompare(right.file));
273
605
  const titles = files
274
606
  .filter(({ file }) => file.endsWith('.test.ts'))
275
- .flatMap(({ file, text, registrations }) => registrations.map((token) => ({ file, title: decodeTitle(text, token) })));
607
+ .flatMap(({ file, text, registrations }) => registrations.flatMap(({ token }) => token === undefined ? [] : [{ file, title: decodeTitle(text, token) }]));
276
608
  // A surviving registration supplies its title count, never evidence that a lost duplicate moved.
277
- const holders = files.flatMap(({ file, text, tokens, registrations }) => collectLiterals(tokens)
278
- .filter((token) => !registrations.includes(token))
279
- .map((token) => ({ file, title: decodeTitle(text, token) })));
609
+ const holders = files.flatMap(({ file, text, tokens, registrations }) => {
610
+ const registered = new Set(registrations.flatMap(({ token }) => (token === undefined ? [] : [token])));
611
+ return collectLiterals(tokens)
612
+ .filter((token) => !registered.has(token))
613
+ .map((token) => ({ file, title: decodeTitle(text, token) }));
614
+ });
280
615
  const deficits = [...Map.groupBy(baseline, ({ title }) => title)].flatMap(([title, entries]) => {
281
616
  const survivors = titles.filter((candidate) => candidate.title === title);
282
617
  const unmatched = [...Map.groupBy(entries, ({ file }) => file)].flatMap(([file, local]) => local.slice(survivors.filter((candidate) => candidate.file === file).length));
@@ -297,16 +632,50 @@ function readReport(base, root) {
297
632
  count: baseline.length,
298
633
  missing: classified.flatMap(({ entry, holder }) => (holder === undefined ? [entry] : [])),
299
634
  moved: classified.flatMap(({ entry, holder }) => holder === undefined ? [] : [{ file: entry.file, title: entry.title, holder }]),
635
+ dynamic: [
636
+ ...baselineFiles.flatMap(({ file, registrations }) => readDynamic(file, registrations, 'baseline')),
637
+ ...files.flatMap(({ file, registrations }) => readDynamic(file, registrations, 'working')),
638
+ ],
639
+ lostDynamic: readLostDynamic(baselineFiles, files),
300
640
  };
301
641
  }
302
- function renderReport(report) {
642
+ /**
643
+ * Escapes the tab, carriage return, and newline characters of a text-row cell.
644
+ *
645
+ * @param cell - The cell text.
646
+ * @returns The cell with each of those characters written as a backslash sequence.
647
+ */
648
+ export function escapeCell(cell) {
649
+ return cell.replace(/\t/gu, '\\t').replace(/\r/gu, '\\r').replace(/\n/gu, '\\n');
650
+ }
651
+ /**
652
+ * Renders a report as a header line and one tab-separated row per finding.
653
+ *
654
+ * @param report - The report to render.
655
+ * @returns The text, without a trailing line feed.
656
+ */
657
+ export function renderReport(report) {
303
658
  return [
304
- `titles: ${report.count} baseline title(s) at ${report.base}, ${report.missing.length} missing, ${report.moved.length} moved`,
305
- ...report.missing.map(({ file, title, deficit }) => `missing\t${file}\t${title}\t${deficit}`),
306
- ...report.moved.map(({ file, title, holder }) => `moved\t${file}\t${title}\t${holder}`),
659
+ `titles: ${report.count} baseline title(s) at ${report.base}, ${report.missing.length} missing, ${report.moved.length} moved, ${report.dynamic.length} dynamic, ${report.lostDynamic.length} lost dynamic`,
660
+ ...report.missing.map(({ file, title, deficit }) => ['missing', file, title, String(deficit)].map(escapeCell).join('\t')),
661
+ ...report.moved.map(({ file, title, holder }) => ['moved', file, title, holder].map(escapeCell).join('\t')),
662
+ ...report.dynamic.map(({ file, spelling, tree }) => ['dynamic', file, tree, spelling].map(escapeCell).join('\t')),
663
+ ...report.lostDynamic.map(({ file, deficit }) => ['lost-dynamic', file, String(deficit)].map(escapeCell).join('\t')),
307
664
  ].join('\n');
308
665
  }
309
- function main(argv) {
666
+ /**
667
+ * Runs the comparison and prints its report.
668
+ *
669
+ * @param argv - The arguments after the script path.
670
+ * @returns The exit code: 0 clean, 3 a missing or moved title or a lost-dynamic file, 2 a tool
671
+ * failure, 64 usage.
672
+ */
673
+ export function main(argv) {
674
+ const unknown = readUnknownFlags(argv, FLAGS);
675
+ if (unknown.length > 0) {
676
+ console.error(`titles: ${unknown.map(describeName).join(', ')} is not a flag this script reads; give each value as its own argument. ${USAGE}`);
677
+ return 64;
678
+ }
310
679
  const missing = readMissingFlags(argv, ['--base', '--root', '--out']);
311
680
  if (missing.length > 0) {
312
681
  console.error(`titles: ${missing.join(', ')} given with no value`);
@@ -315,7 +684,11 @@ function main(argv) {
315
684
  const base = readOption(argv, '--base');
316
685
  const out = readOption(argv, '--out');
317
686
  if (base === undefined || (out !== undefined && !['.json', '.txt'].includes(extname(out)))) {
318
- console.error('usage: titles.ts --base REV [--root DIR] [--json] [--out FILE.json|FILE.txt]');
687
+ console.error(USAGE);
688
+ return 64;
689
+ }
690
+ if (base.startsWith('-')) {
691
+ console.error(`titles: --base ${describeName(base)} opens with "-"; give a revision name or hash`);
319
692
  return 64;
320
693
  }
321
694
  try {
@@ -328,11 +701,13 @@ function main(argv) {
328
701
  writeFileSync(target, `${extname(target) === '.json' ? json : text}\n`);
329
702
  }
330
703
  console.log(argv.includes('--json') ? json : text);
331
- return report.missing.length > 0 ? 3 : 0;
704
+ return report.missing.length + report.moved.length + report.lostDynamic.length > 0 ? 3 : 0;
332
705
  }
333
706
  catch (error) {
334
707
  console.error(`titles: ${error instanceof Error ? error.message : String(error)}`);
335
708
  return 2;
336
709
  }
337
710
  }
338
- process.exitCode = main(process.argv.slice(2));
711
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
712
+ process.exitCode = main(process.argv.slice(2));
713
+ }