@orkestrel/scaffold 0.0.102 → 0.0.104

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 (89) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +84 -1
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +21 -6
  3. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +338 -0
  4. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +3 -39
  5. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +433 -47
  6. package/dist/agents/skills/orkestrel-publish/scripts/window.js +2 -1
  7. package/dist/bin/main.js +16 -9
  8. package/dist/bin/main.js.map +1 -1
  9. package/dist/host/agents/orchestration.md +3 -3
  10. package/dist/host/agents/skills/enterprise-bootstrap/references/components.md +2 -2
  11. package/dist/host/agents/skills/enterprise-bootstrap/references/inspection.md +1 -1
  12. package/dist/host/agents/skills/orkestrel-build/SKILL.md +2 -2
  13. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +2 -2
  14. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +1 -1
  15. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +3 -3
  16. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  17. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +1 -0
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +100 -1
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +27 -6
  20. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +4 -4
  21. package/dist/host/agents/skills/orkestrel-falsify/agents/openai.yaml +1 -1
  22. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +8 -7
  23. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +6 -6
  24. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +6 -1
  25. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +1700 -0
  26. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -2
  27. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +48 -0
  28. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +4 -2
  29. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +425 -0
  30. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +2 -2
  31. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +9 -8
  32. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +3 -3
  33. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +2 -2
  34. package/dist/host/agents/skills/orkestrel-publish/references/release.md +1 -0
  35. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +56 -13
  36. package/dist/host/agents/skills/orkestrel-publish/references/window.md +6 -6
  37. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +5 -49
  38. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +646 -49
  39. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +2 -1
  40. package/dist/host/agents/transports/codex.md +1 -1
  41. package/dist/host/claude/agents/orkestrel.md +54 -53
  42. package/dist/host/claude/agents/reviewer.md +4 -4
  43. package/dist/host/claude/rules/documentation.md +2 -1
  44. package/dist/host/claude/rules/patterns.md +1 -1
  45. package/dist/host/claude/rules/quality.md +2 -2
  46. package/dist/host/claude/rules/styles.md +1 -1
  47. package/dist/host/claude/rules/tests.md +34 -8
  48. package/dist/host/claude/rules/typescript.md +29 -0
  49. package/dist/host/claude/rules/workspace.md +35 -28
  50. package/dist/host/claude/rules/writing.md +34 -26
  51. package/dist/host/claude/settings.json +2 -0
  52. package/dist/host/claude/skills/orkestrel-debrief/SKILL.md +1 -1
  53. package/dist/host/claude/skills/orkestrel-falsify/SKILL.md +1 -1
  54. package/dist/host/codex/agents/analyst.toml +3 -3
  55. package/dist/host/codex/agents/reviewer.toml +4 -4
  56. package/dist/host/configs/helpers.ts +4 -16
  57. package/dist/host/configs/policy.ts +13 -2
  58. package/dist/host/dotfiles/editorconfig +0 -1
  59. package/dist/host/dotfiles/gitattributes +0 -1
  60. package/dist/host/dotfiles/gitignore +0 -1
  61. package/dist/host/dotfiles/oxlintrc.json +25 -11
  62. package/dist/host/guides/agent.md +1638 -598
  63. package/dist/host/guides/budget.md +89 -29
  64. package/dist/host/guides/form.md +477 -91
  65. package/dist/host/guides/mcp.md +14 -2
  66. package/dist/host/guides/ollama.md +97 -73
  67. package/dist/host/guides/probe.md +1 -1
  68. package/dist/host/guides/scaffold.md +158 -101
  69. package/dist/host/guides/study.md +2635 -0
  70. package/dist/host/guides/table.md +225 -15
  71. package/dist/host/guides/terminal.md +22 -20
  72. package/dist/host/guides/test.md +689 -255
  73. package/dist/host/manifest.json +88 -64
  74. package/dist/host/tests/config.test.ts +189 -146
  75. package/dist/host/tests/policy.test.ts +39 -1
  76. package/dist/host/tests/setupPolicy.ts +104 -57
  77. package/dist/src/core/index.cjs +1387 -611
  78. package/dist/src/core/index.cjs.map +1 -1
  79. package/dist/src/core/index.d.cts +35 -20
  80. package/dist/src/core/index.d.ts +35 -20
  81. package/dist/src/core/index.js +1385 -612
  82. package/dist/src/core/index.js.map +1 -1
  83. package/dist/src/server/index.cjs +12 -12
  84. package/dist/src/server/index.cjs.map +1 -1
  85. package/dist/src/server/index.d.cts +12 -12
  86. package/dist/src/server/index.d.ts +12 -12
  87. package/dist/src/server/index.js +12 -12
  88. package/dist/src/server/index.js.map +1 -1
  89. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  /** Names the directories every tree walk in a skill script skips. */
5
5
  export const SKIPPED_DIRECTORIES = new Set([
@@ -10,6 +10,89 @@ export const SKIPPED_DIRECTORIES = new Set([
10
10
  '.orkestrel',
11
11
  '.idea',
12
12
  ]);
13
+ /** Names the manifest fields that hold specs, including nested overrides. */
14
+ export const SPEC_FIELDS = Object.freeze([
15
+ 'dependencies',
16
+ 'devDependencies',
17
+ 'peerDependencies',
18
+ 'optionalDependencies',
19
+ 'overrides',
20
+ ]);
21
+ /** Matches npm local prefixes and path starts, including drive-relative paths. */
22
+ export const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu;
23
+ /** Matches archive specs with no protocol and a tgz, tar.gz, or tar extension. */
24
+ export const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu;
25
+ /** Matches hosted user/repository shorthand even when its name ends in an archive extension. */
26
+ export const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u;
27
+ /**
28
+ * Checks whether a value is a non-null, non-array object.
29
+ *
30
+ * @param value - The value to inspect.
31
+ * @returns True if the value is an object other than null or an array; false otherwise.
32
+ * @example
33
+ * isRecord({ dependencies: {} }) // true
34
+ * isRecord([]) // false
35
+ */
36
+ export function isRecord(value) {
37
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
38
+ }
39
+ /**
40
+ * Checks whether npm resolves a spec from the local host.
41
+ *
42
+ * @param spec - The package spec, with leading whitespace ignored.
43
+ * @returns True if the spec names a local path or archive; false otherwise.
44
+ * @example
45
+ * matchesLocalSpec('file:../other') // true
46
+ * matchesLocalSpec('owner/package.tgz') // false
47
+ */
48
+ export function matchesLocalSpec(spec) {
49
+ const text = spec.trimStart();
50
+ return (LOCAL_SPEC_PATTERN.test(text) ||
51
+ (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text)));
52
+ }
53
+ /**
54
+ * Collects string specs with the dotted path of each nested record member.
55
+ *
56
+ * @param path - The field path that prefixes each finding.
57
+ * @param value - A string or finite, acyclic manifest record; other values contribute no specs.
58
+ * @returns The path and spec pairs in record order.
59
+ * @example
60
+ * collectSpecs('overrides', { a: { b: 'file:../other' } })
61
+ * // [['overrides.a.b', 'file:../other']]
62
+ */
63
+ export function collectSpecs(path, value) {
64
+ if (typeof value === 'string')
65
+ return [[path, value]];
66
+ if (!isRecord(value))
67
+ return [];
68
+ return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child));
69
+ }
70
+ /**
71
+ * Finds local specs in the manifest's spec fields, including nested overrides.
72
+ *
73
+ * @param manifest - A finite, acyclic package manifest record.
74
+ * @returns Each local spec's dotted field path and spec, in field and record order.
75
+ * @example
76
+ * scanLocalSpecs({ overrides: { a: { b: 'file:../other' } } })
77
+ * // [['overrides.a.b', 'file:../other']]
78
+ */
79
+ export function scanLocalSpecs(manifest) {
80
+ return SPEC_FIELDS.flatMap((field) => collectSpecs(field, manifest[field]).filter(([, spec]) => matchesLocalSpec(spec)));
81
+ }
82
+ /**
83
+ * Removes a tree with bounded retries for transient host removal errors.
84
+ *
85
+ * @param path - The directory to remove; a missing path succeeds.
86
+ * @returns Nothing after the removal succeeds.
87
+ * @throws Thrown when removal fails after the bounded retries or the host refuses the path.
88
+ * @remarks
89
+ * Uses Node's recursive removal with five retries and a 100 ms retry delay.
90
+ * @example
91
+ * removeTree(scratch.path)
92
+ */
93
+ export function removeTree(path) {
94
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
95
+ }
13
96
  /**
14
97
  * Reads the value that follows a flag.
15
98
  *
@@ -41,6 +41,18 @@ function killTree(pid) {
41
41
  }
42
42
  catch { }
43
43
  }
44
+ function startCommand(command, args, journal, errors) {
45
+ try {
46
+ return spawn(command, args, { stdio: ['ignore', journal, errors], windowsHide: true });
47
+ }
48
+ catch (error) {
49
+ return {
50
+ code: null,
51
+ signal: null,
52
+ error: error instanceof Error ? error.message : String(error),
53
+ };
54
+ }
55
+ }
44
56
  function waitForExit(child) {
45
57
  return new Promise((resolve) => {
46
58
  child.on('error', (error) => resolve({ code: null, signal: null, error: error.message }));
@@ -79,21 +91,24 @@ async function main(argv) {
79
91
  const errorsFd = openSync(errors, 'w');
80
92
  rmSync(`${journal}.pid`, { force: true });
81
93
  const started = Date.now();
82
- const child = spawn(command, args, { stdio: ['ignore', journalFd, errorsFd], windowsHide: true });
83
- if (child.pid !== undefined) {
94
+ const launched = startCommand(command, args, journalFd, errorsFd);
95
+ const child = 'error' in launched ? undefined : launched;
96
+ if (child?.pid !== undefined) {
84
97
  writeFileSync(`${journal}.pid`, `${child.pid}\n`);
85
98
  console.log(JSON.stringify({ pid: child.pid, journal, errors }));
86
99
  }
87
100
  // The signal aborts once the cap elapses, so whether it aborted is whether the cap ended the run.
88
101
  // The timer cannot be cleared, so a cap that elapses after the child exited kills nothing: a
89
102
  // reused pid is not this command's.
90
- const deadline = capMilliseconds === undefined ? undefined : AbortSignal.timeout(capMilliseconds);
103
+ const deadline = child === undefined || capMilliseconds === undefined
104
+ ? undefined
105
+ : AbortSignal.timeout(capMilliseconds);
91
106
  deadline?.addEventListener('abort', () => {
92
- if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
107
+ if (child?.pid !== undefined && child.exitCode === null && child.signalCode === null) {
93
108
  killTree(child.pid);
94
109
  }
95
110
  }, { once: true });
96
- const exit = await waitForExit(child);
111
+ const exit = 'error' in launched ? launched : await waitForExit(launched);
97
112
  const capped = deadline?.aborted === true;
98
113
  closeSync(journalFd);
99
114
  closeSync(errorsFd);
@@ -102,7 +117,7 @@ async function main(argv) {
102
117
  if (status)
103
118
  writeFileSync(`${journal}.status-after.txt`, readStatus());
104
119
  console.log(JSON.stringify({
105
- pid: child.pid,
120
+ pid: child?.pid,
106
121
  exit: exit.error === undefined ? exit.code : 127,
107
122
  signal: exit.signal,
108
123
  capped,
@@ -0,0 +1,338 @@
1
+ // Run from the checkout root:
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.
9
+ // Track lexical brace context without a syntax tree. Function-expression and arrow bodies cannot
10
+ // 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.
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ 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) {
19
+ const raw = text.slice(token.start + 1, token.end - 1);
20
+ return token.category === 'template' && token.children.length > 0
21
+ ? raw
22
+ : raw.replace(/\\(['"`\\])/gu, '$1');
23
+ }
24
+ class TokenScanner {
25
+ #text;
26
+ #file;
27
+ #parentheses = [];
28
+ #braces = [];
29
+ #position = 0;
30
+ constructor(text, file) {
31
+ this.#text = text;
32
+ this.#file = file;
33
+ }
34
+ scan() {
35
+ return this.#readCode(false, 0);
36
+ }
37
+ #readCode(expression, depth) {
38
+ if (depth > 256)
39
+ throw new Error(`${this.#file}: token nesting exceeds 256`);
40
+ const tokens = [];
41
+ while (this.#position < this.#text.length) {
42
+ const start = this.#position;
43
+ const rest = this.#text.slice(start);
44
+ const character = this.#text[start];
45
+ const previous = tokens.findLast((token) => token.category !== 'line' &&
46
+ token.category !== 'block' &&
47
+ this.#text.slice(token.start, token.end).trim().length > 0);
48
+ const spelling = previous === undefined ? '' : this.#text.slice(previous.start, previous.end);
49
+ if (character === '}' && expression) {
50
+ this.#position += 1;
51
+ return tokens;
52
+ }
53
+ if (character === '{') {
54
+ const block = previous === undefined
55
+ ? this.#braces.at(-1)?.category !== 'template'
56
+ : previous.category === 'code' &&
57
+ /^(?:\)|=>|;|\{|\}|else|try|finally|do)$/u.test(spelling);
58
+ this.#braces.push({
59
+ category: block ? 'block' : 'expression',
60
+ ambiguous: block && (spelling === '=>' || previous?.ambiguous === true),
61
+ });
62
+ this.#position += 1;
63
+ tokens.push({ category: 'code', start, end: this.#position, children: [] });
64
+ tokens.push(...this.#readCode(true, depth + 1));
65
+ const brace = this.#braces.pop();
66
+ tokens.push({
67
+ category: 'code',
68
+ start: this.#position - 1,
69
+ end: this.#position,
70
+ children: [],
71
+ prefix: brace?.category === 'block',
72
+ ambiguous: brace?.ambiguous === true,
73
+ });
74
+ continue;
75
+ }
76
+ if (character === '`') {
77
+ tokens.push(this.#readTemplate(depth + 1));
78
+ continue;
79
+ }
80
+ if (character === '/' &&
81
+ !rest.startsWith('//') &&
82
+ !rest.startsWith('/*') &&
83
+ previous?.ambiguous === true)
84
+ throw new Error(`${this.#file}:${start}: Ambiguous brace before slash`);
85
+ const regex = character === '/' &&
86
+ !rest.startsWith('//') &&
87
+ !rest.startsWith('/*') &&
88
+ (previous === undefined ||
89
+ previous.prefix === true ||
90
+ (previous.category === 'code' &&
91
+ (REGEX_PREFIX.test(spelling) ||
92
+ !/^(?:[\p{ID_Continue}$]+|[\])}]|\+\+|--)$/u.test(spelling))));
93
+ const category = rest.startsWith('//')
94
+ ? 'line'
95
+ : rest.startsWith('/*')
96
+ ? 'block'
97
+ : character === "'" || character === '"'
98
+ ? 'string'
99
+ : regex
100
+ ? 'regex'
101
+ : 'code';
102
+ const match = category === 'line'
103
+ ? /^\/\/[^\r\n]*/u.exec(rest)
104
+ : category === 'block'
105
+ ? /^\/\*[\s\S]*?\*\//u.exec(rest)
106
+ : category === 'string'
107
+ ? (character === "'"
108
+ ? /^'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n\u2028\u2029])*'/u
109
+ : /^"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n\u2028\u2029])*"/u).exec(rest)
110
+ : category === 'regex'
111
+ ? /^\/(?:\\[^\r\n]|\[(?:\\[^\r\n]|[^\]\\\r\n])*\]|[^/\\[\r\n])*\/[a-z]*/iu.exec(rest)
112
+ : /^(?:[\p{ID_Start}_$][\p{ID_Continue}$]*|\d[\w.]*|\s+|\+\+|--|=>|.)/u.exec(rest);
113
+ if (match === null)
114
+ throw new Error(`${this.#file}:${start}: Unclosed ${category} token`);
115
+ this.#position += match[0].length;
116
+ if (category === 'code' && character === '(') {
117
+ const words = tokens
118
+ .filter((token) => token.category === 'code')
119
+ .map((token) => this.#text.slice(token.start, token.end))
120
+ .filter((word) => word.trim().length > 0);
121
+ const keyword = words.findLastIndex((word) => word === 'function');
122
+ const callable = keyword >= 0 &&
123
+ words
124
+ .slice(keyword + 1)
125
+ .every((word) => word === '*' || /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u.test(word));
126
+ const before = words[words[keyword - 1] === 'async' ? keyword - 2 : keyword - 1];
127
+ this.#parentheses.push({
128
+ prefix: /^(?:if|while|for|with|switch|catch)$/u.test(spelling),
129
+ ambiguous: callable &&
130
+ (before === undefined
131
+ ? this.#braces.at(-1)?.category === 'template'
132
+ : /^(?:[=(,:[?+\-*/%&|^!~<>]|=>|return|throw|yield|await|case|new|void|typeof|delete|instanceof|in|of)$/u.test(before)),
133
+ });
134
+ }
135
+ const parenthesis = category === 'code' && character === ')' ? this.#parentheses.pop() : undefined;
136
+ tokens.push({
137
+ category,
138
+ start,
139
+ end: this.#position,
140
+ children: [],
141
+ prefix: parenthesis?.prefix === true,
142
+ ambiguous: parenthesis?.ambiguous === true,
143
+ });
144
+ }
145
+ if (expression)
146
+ throw new Error(`${this.#file}:${this.#position}: Unclosed template expression or brace`);
147
+ return tokens;
148
+ }
149
+ #readTemplate(depth) {
150
+ const start = this.#position;
151
+ const children = [];
152
+ this.#position += 1;
153
+ while (this.#position < this.#text.length) {
154
+ if (this.#text[this.#position] === '\\') {
155
+ this.#position += 2;
156
+ }
157
+ else if (this.#text[this.#position] === '`') {
158
+ this.#position += 1;
159
+ return { category: 'template', start, end: this.#position, children };
160
+ }
161
+ else if (this.#text.startsWith('${', this.#position)) {
162
+ const opening = this.#position;
163
+ this.#position += 2;
164
+ this.#braces.push({ category: 'template', ambiguous: false });
165
+ const expression = this.#readCode(true, depth);
166
+ this.#braces.pop();
167
+ children.push({
168
+ category: 'code',
169
+ start: opening,
170
+ end: this.#position,
171
+ children: expression,
172
+ });
173
+ }
174
+ else {
175
+ this.#position += 1;
176
+ }
177
+ }
178
+ throw new Error(`${this.#file}:${start}: Unclosed template token`);
179
+ }
180
+ }
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;
192
+ }
193
+ throw new Error(`${location}: Unbalanced each arguments`);
194
+ }
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
+ }));
225
+ return [
226
+ ...tokens.filter((token) => starts.has(token.start)),
227
+ ...tokens.flatMap((token) => readRegistrations(text, token.children, file)),
228
+ ];
229
+ }
230
+ function collectLiterals(tokens) {
231
+ return tokens.flatMap((token) => [
232
+ ...(token.category === 'string' || token.category === 'template' ? [token] : []),
233
+ ...collectLiterals(token.children),
234
+ ]);
235
+ }
236
+ function readGit(root, args) {
237
+ const result = spawnSync('git', ['-C', root, ...args], {
238
+ encoding: 'utf8',
239
+ windowsHide: true,
240
+ maxBuffer: 256 * 1024 * 1024,
241
+ });
242
+ if (result.error !== undefined || result.status !== 0) {
243
+ throw new Error(`git: ${result.error?.message ?? result.stderr.trim()}`);
244
+ }
245
+ return result.stdout;
246
+ }
247
+ function readReport(base, root) {
248
+ const baseline = readGit(root, ['ls-tree', '-r', '-z', '--name-only', base, '--', 'tests'])
249
+ .split('\0')
250
+ .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) => ({
254
+ file,
255
+ title: decodeTitle(text, token),
256
+ }));
257
+ });
258
+ const directory = join(root, 'tests');
259
+ const files = (existsSync(directory) ? listFiles(directory) : [])
260
+ .filter(({ path }) => path.endsWith('.ts'))
261
+ .map(({ path }) => {
262
+ const file = normalizeSlashes(relative(root, path));
263
+ const text = readFileSync(path, 'utf8');
264
+ const tokens = new TokenScanner(text, file).scan();
265
+ return {
266
+ file,
267
+ text,
268
+ tokens,
269
+ registrations: file.endsWith('.test.ts') ? readRegistrations(text, tokens, file) : [],
270
+ };
271
+ })
272
+ .sort((left, right) => left.file.localeCompare(right.file));
273
+ const titles = files
274
+ .filter(({ file }) => file.endsWith('.test.ts'))
275
+ .flatMap(({ file, text, registrations }) => registrations.map((token) => ({ file, title: decodeTitle(text, token) })));
276
+ // 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) })));
280
+ const deficits = [...Map.groupBy(baseline, ({ title }) => title)].flatMap(([title, entries]) => {
281
+ const survivors = titles.filter((candidate) => candidate.title === title);
282
+ const unmatched = [...Map.groupBy(entries, ({ file }) => file)].flatMap(([file, local]) => local.slice(survivors.filter((candidate) => candidate.file === file).length));
283
+ const surplus = [...Map.groupBy(survivors, ({ file }) => file)].flatMap(([file, local]) => local.slice(entries.filter((candidate) => candidate.file === file).length));
284
+ return [...Map.groupBy(unmatched.slice(surplus.length), ({ file }) => file)].map(([file, lost]) => ({
285
+ file,
286
+ title,
287
+ deficit: lost.length,
288
+ }));
289
+ });
290
+ const classified = deficits.map((entry) => ({
291
+ entry,
292
+ holder: holders.find(({ title }) => title === entry.title)?.file,
293
+ }));
294
+ return {
295
+ base,
296
+ root: normalizeSlashes(root),
297
+ count: baseline.length,
298
+ missing: classified.flatMap(({ entry, holder }) => (holder === undefined ? [entry] : [])),
299
+ moved: classified.flatMap(({ entry, holder }) => holder === undefined ? [] : [{ file: entry.file, title: entry.title, holder }]),
300
+ };
301
+ }
302
+ function renderReport(report) {
303
+ 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}`),
307
+ ].join('\n');
308
+ }
309
+ function main(argv) {
310
+ const missing = readMissingFlags(argv, ['--base', '--root', '--out']);
311
+ if (missing.length > 0) {
312
+ console.error(`titles: ${missing.join(', ')} given with no value`);
313
+ return 64;
314
+ }
315
+ const base = readOption(argv, '--base');
316
+ const out = readOption(argv, '--out');
317
+ 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]');
319
+ return 64;
320
+ }
321
+ try {
322
+ const report = readReport(base, resolve(readOption(argv, '--root') ?? process.cwd()));
323
+ const json = JSON.stringify(report);
324
+ const text = renderReport(report);
325
+ if (out !== undefined) {
326
+ const target = resolve(out);
327
+ mkdirSync(dirname(target), { recursive: true });
328
+ writeFileSync(target, `${extname(target) === '.json' ? json : text}\n`);
329
+ }
330
+ console.log(argv.includes('--json') ? json : text);
331
+ return report.missing.length > 0 ? 3 : 0;
332
+ }
333
+ catch (error) {
334
+ console.error(`titles: ${error instanceof Error ? error.message : String(error)}`);
335
+ return 2;
336
+ }
337
+ }
338
+ process.exitCode = main(process.argv.slice(2));
@@ -36,9 +36,9 @@
36
36
  // Exit 0 on success, 1 when a command or a proof failed, 3 when --status reports drift, 64 on usage.
37
37
  import { spawnSync } from 'node:child_process';
38
38
  import { createHash } from 'node:crypto';
39
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
39
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync, } from 'node:fs';
40
40
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
41
- import { normalizeSlashes, parseJsonRecord, readMissingFlags, readOption, readOptions, readString, runNpm, } from "../../orkestrel-dispatch/scripts/helpers.js";
41
+ import { isRecord, normalizeSlashes, parseJsonRecord, readMissingFlags, readOption, readOptions, readString, removeTree, runNpm, scanLocalSpecs, } from "../../orkestrel-dispatch/scripts/helpers.js";
42
42
  const FIELDS = ['dependencies', 'devDependencies'];
43
43
  const MODES = ['pin', 'restore', 'status'];
44
44
  const TARBALL_DIRECTORY = 'tmp/tarballs';
@@ -123,21 +123,6 @@ const OVERRIDE_PRECEDERS = [
123
123
  'bundleDependencies',
124
124
  'resolutions',
125
125
  ];
126
- // The manifest blocks whose values are dependency specs; `overrides` nests records of them.
127
- const SPEC_BLOCKS = [
128
- 'dependencies',
129
- 'devDependencies',
130
- 'peerDependencies',
131
- 'optionalDependencies',
132
- 'overrides',
133
- ];
134
- // npm-package-arg reads a spec as local when it carries a `file:` or `link:` prefix or begins like a
135
- // path: a dot, `~/`, a separator, or a drive letter.
136
- const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu;
137
- // It also reads a spec as local when the spec ends in an archive extension and carries no protocol...
138
- const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu;
139
- // ...unless the spec is a `user/repo` shorthand, which names a hosted repository whatever it ends in.
140
- const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u;
141
126
  // An optional `@scope/` and a name of the characters a registry URL carries untouched; a path
142
127
  // separator beyond the scope's one `/`, an absolute form, and a drive form fall outside it.
143
128
  const PACKAGE_NAME_PATTERN = /^(?:@[A-Za-z0-9~-][A-Za-z0-9._~-]*\/)?[A-Za-z0-9~-][A-Za-z0-9._~-]*$/u;
@@ -147,14 +132,6 @@ const USAGE = [
147
132
  ' swap.ts --restore --consumer DIR [--package NAME ...] [--json]',
148
133
  ' swap.ts --status --consumer DIR [--json]',
149
134
  ].join('\n');
150
- function isRecord(value) {
151
- return typeof value === 'object' && value !== null && !Array.isArray(value);
152
- }
153
- function isLocalSpec(spec) {
154
- const text = spec.trimStart();
155
- return (LOCAL_SPEC_PATTERN.test(text) ||
156
- (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text)));
157
- }
158
135
  function isPackageName(name) {
159
136
  return (name.length <= PACKAGE_NAME_LIMIT && !name.includes('..') && PACKAGE_NAME_PATTERN.test(name));
160
137
  }
@@ -632,9 +609,6 @@ function replaceFile(path, text) {
632
609
  writeFileSync(temporary, text);
633
610
  renameSync(temporary, path);
634
611
  }
635
- function removeTree(path) {
636
- rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
637
- }
638
612
  // Vite keys its pre-bundle on the installed version, so a same-version tarball would serve stale bytes.
639
613
  function installConsumer(consumer) {
640
614
  invokeNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund'], consumer);
@@ -842,17 +816,7 @@ function readPackedManifest(directory) {
842
816
  // or not a ledger recorded the spec.
843
817
  function scanPackedSpecs(source) {
844
818
  const manifest = loadPackedManifest(source.directory);
845
- return SPEC_BLOCKS.flatMap((block) => collectSpecs(block, manifest[block]).flatMap(([path, spec]) => isLocalSpec(spec)
846
- ? [`${source.name} would pack a local dependency spec: ${path} is ${spec}`]
847
- : []));
848
- }
849
- // An `overrides` value can nest records, so the path names every key down to the spec.
850
- function collectSpecs(path, value) {
851
- if (typeof value === 'string')
852
- return [[path, value]];
853
- if (!isRecord(value))
854
- return [];
855
- return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child));
819
+ return scanLocalSpecs(manifest).map(([path, spec]) => `${source.name} would pack a local dependency spec: ${path} is ${spec}`);
856
820
  }
857
821
  // A source that carries a ledger of its own has its manifest rewritten to the recorded ranges for the
858
822
  // pack and restored byte for byte whether the pack succeeds or throws, so a consumer never receives a