@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,46 +1,122 @@
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'
14
- import { dirname, extname, join, relative, resolve } from 'node:path'
15
29
  import {
16
- listFiles,
17
- normalizeSlashes,
18
- readMissingFlags,
19
- readOption,
20
- } from '../../orkestrel-dispatch/scripts/helpers.ts'
30
+ existsSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ readFileSync,
34
+ realpathSync,
35
+ statSync,
36
+ writeFileSync,
37
+ } from 'node:fs'
38
+ import { dirname, extname, join, relative, resolve } from 'node:path'
39
+
40
+ /** Lists the flags this script reads, value flags and switches alike. */
41
+ export const FLAGS: readonly string[] = ['--base', '--root', '--json', '--out']
42
+
43
+ /** Holds the usage line printed on a usage refusal. */
44
+ export const USAGE = 'usage: titles.ts --base REV [--root DIR] [--json] [--out FILE.json|FILE.txt]'
45
+
46
+ /** Caps the characters of a name an error message echoes. */
47
+ export const NAME_LIMIT = 80
48
+
49
+ /** Names the directories a tree walk skips, so build output and notes stay unread. */
50
+ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
51
+ 'node_modules',
52
+ '.git',
53
+ 'dist',
54
+ 'tmp',
55
+ '.orkestrel',
56
+ '.idea',
57
+ ])
58
+
59
+ /** Matches the keywords after which a slash opens a regular expression. */
60
+ export const REGEX_PREFIX =
61
+ /^(?:return|throw|case|delete|void|typeof|instanceof|in|of|yield|await|else|do)$/u
62
+
63
+ /** Matches an identifier as one word. */
64
+ export const IDENTIFIER_PATTERN = /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u
65
+
66
+ /** Names the identifiers that open a test registration. */
67
+ export const REGISTRARS: ReadonlySet<string> = new Set(['it', 'test', 'describe', 'suite'])
68
+
69
+ /** Names the calls whose argument list or table precedes the registration call. */
70
+ export const ARGUMENT_MODIFIERS: ReadonlySet<string> = new Set(['each', 'skipIf', 'runIf', 'for'])
71
+
72
+ /** Lists the tokens that open a bracket group. */
73
+ export const OPENERS: readonly string[] = ['(', '[', '{']
74
+
75
+ /** Lists the tokens that close a bracket group. */
76
+ export const CLOSERS: readonly string[] = [')', ']', '}']
21
77
 
22
- interface Title {
78
+ /** Describes one registered title and the file that registers it. */
79
+ export interface Title {
23
80
  readonly file: string
24
81
  readonly title: string
25
82
  }
26
83
 
27
- interface Missing extends Title {
84
+ /** Describes a baseline title that has fewer registrations than before. */
85
+ export interface Missing extends Title {
28
86
  readonly deficit: number
29
87
  }
30
88
 
31
- interface Moved extends Title {
89
+ /** Describes a lost title that another string literal still spells. */
90
+ export interface Moved extends Title {
32
91
  readonly holder: string
33
92
  }
34
93
 
35
- interface Report {
94
+ /** Describes a registration whose title is no string or template literal. */
95
+ export interface Dynamic {
96
+ readonly file: string
97
+ readonly spelling: string
98
+ readonly tree: 'baseline' | 'working'
99
+ }
100
+
101
+ /** Describes a file that registers fewer non-literal titles than it did at the baseline. */
102
+ export interface LostDynamic {
103
+ readonly file: string
104
+ readonly deficit: number
105
+ }
106
+
107
+ /** Holds the comparison of the working tree with the baseline. */
108
+ export interface Report {
36
109
  readonly base: string
37
110
  readonly root: string
38
111
  readonly count: number
39
112
  readonly missing: readonly Missing[]
40
113
  readonly moved: readonly Moved[]
114
+ readonly dynamic: readonly Dynamic[]
115
+ readonly lostDynamic: readonly LostDynamic[]
41
116
  }
42
117
 
43
- interface Token {
118
+ /** Describes one lexical token, with the tokens nested in its template expressions. */
119
+ export interface Token {
44
120
  readonly category: 'code' | 'line' | 'block' | 'string' | 'template' | 'regex'
45
121
  readonly start: number
46
122
  readonly end: number
@@ -49,28 +125,154 @@ interface Token {
49
125
  readonly ambiguous?: boolean
50
126
  }
51
127
 
52
- interface Brace {
128
+ /** Describes an open brace and what the scanner knows of its context. */
129
+ export interface Brace {
53
130
  readonly category: 'block' | 'expression' | 'template'
54
131
  readonly ambiguous: boolean
55
132
  }
56
133
 
57
- interface Parenthesis {
134
+ /** Describes an open parenthesis and what the scanner knows of its context. */
135
+ export interface Parenthesis {
58
136
  readonly prefix: boolean
59
137
  readonly ambiguous: boolean
60
138
  }
61
139
 
62
- const TITLE_PATTERN = /^\s*(?:it|test|describe)(?:\.\w+)*\(/gmu
63
- const REGEX_PREFIX =
64
- /^(?:return|throw|case|delete|void|typeof|instanceof|in|of|yield|await|else|do)$/u
140
+ /** Describes one test registration: its literal title token, or its spelling when none is literal. */
141
+ export interface Registration {
142
+ readonly token: Token | undefined
143
+ readonly spelling: string
144
+ }
145
+
146
+ /** Describes one file a tree walk found. */
147
+ export interface WalkedFile {
148
+ readonly path: string
149
+ readonly modified: number
150
+ }
151
+
152
+ /**
153
+ * Normalizes a host path to forward slashes.
154
+ *
155
+ * @param path - A host path.
156
+ * @returns The same path with every backslash replaced by a slash.
157
+ */
158
+ export function normalizeSlashes(path: string): string {
159
+ return path.split('\\').join('/')
160
+ }
65
161
 
66
- function decodeTitle(text: string, token: Token): string {
162
+ /**
163
+ * Reads the value that follows a flag.
164
+ *
165
+ * @remarks
166
+ * The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
167
+ * and the `--flag=value` form is not read.
168
+ *
169
+ * @param argv - The arguments after the script path.
170
+ * @param name - The flag, such as `--base`.
171
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
172
+ * another flag.
173
+ */
174
+ export function readOption(argv: readonly string[], name: string): string | undefined {
175
+ const index = argv.indexOf(name)
176
+ const value = index === -1 ? undefined : argv[index + 1]
177
+ return value === undefined || value.startsWith('--') ? undefined : value
178
+ }
179
+
180
+ /**
181
+ * Names the value flags a caller gave with no value.
182
+ *
183
+ * @param argv - The arguments after the script path.
184
+ * @param names - The flags that take a value.
185
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
186
+ */
187
+ export function readMissingFlags(
188
+ argv: readonly string[],
189
+ names: readonly string[],
190
+ ): readonly string[] {
191
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
192
+ }
193
+
194
+ /**
195
+ * Names the flags a caller gave that the script does not read.
196
+ *
197
+ * @remarks
198
+ * Reads the arguments before a bare `--`. A `--flag=value` argument is unknown, because the script
199
+ * reads `--flag value` pairs only.
200
+ *
201
+ * @param argv - The arguments after the script path.
202
+ * @param flags - Every flag the script reads, value flags and switches alike.
203
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
204
+ */
205
+ export function readUnknownFlags(
206
+ argv: readonly string[],
207
+ flags: readonly string[],
208
+ ): readonly string[] {
209
+ const end = argv.indexOf('--')
210
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
211
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
212
+ )
213
+ }
214
+
215
+ /**
216
+ * Lists every regular file beneath a directory, skipping the named directories at any depth.
217
+ *
218
+ * @param directory - The directory to walk.
219
+ * @param skipped - The directory names to skip. Default: {@link SKIPPED_DIRECTORIES}.
220
+ * @returns Each file's forward-slash path and modification time, in walk order.
221
+ * @throws Thrown when `directory` does not exist or cannot be read; check `existsSync` first.
222
+ */
223
+ export function listFiles(
224
+ directory: string,
225
+ skipped: ReadonlySet<string> = SKIPPED_DIRECTORIES,
226
+ ): readonly WalkedFile[] {
227
+ const files: WalkedFile[] = []
228
+ const pending = [directory]
229
+ while (pending.length > 0) {
230
+ const current = pending.pop()
231
+ if (current === undefined) break
232
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
233
+ if (skipped.has(entry.name)) continue
234
+ const path = join(current, entry.name)
235
+ if (entry.isDirectory()) pending.push(path)
236
+ else if (entry.isFile()) {
237
+ files.push({ path: normalizeSlashes(path), modified: statSync(path).mtimeMs })
238
+ }
239
+ }
240
+ }
241
+ return files
242
+ }
243
+
244
+ /**
245
+ * Describes a name for an error message, JSON-encoded and length-capped.
246
+ *
247
+ * @param name - The untrusted name to echo.
248
+ * @returns The encoded name, cut where it exceeds the cap.
249
+ */
250
+ export function describeName(name: string): string {
251
+ return JSON.stringify(name.length > NAME_LIMIT ? `${name.slice(0, NAME_LIMIT)}…` : name)
252
+ }
253
+
254
+ /**
255
+ * Decodes the title a string or template token spells.
256
+ *
257
+ * @param text - The source text.
258
+ * @param token - A string or template token.
259
+ * @returns The raw spelling of an interpolated template, otherwise the text with quote and
260
+ * backslash escapes decoded.
261
+ */
262
+ export function decodeTitle(text: string, token: Token): string {
67
263
  const raw = text.slice(token.start + 1, token.end - 1)
68
264
  return token.category === 'template' && token.children.length > 0
69
265
  ? raw
70
266
  : raw.replace(/\\(['"`\\])/gu, '$1')
71
267
  }
72
268
 
73
- class TokenScanner {
269
+ /**
270
+ * Scans JavaScript or TypeScript source into lexical tokens without a syntax tree.
271
+ *
272
+ * @remarks
273
+ * Keeps its position and its brace and parenthesis context in `#` fields, one scanner per scan.
274
+ */
275
+ export class TokenScanner {
74
276
  readonly #text: string
75
277
  readonly #file: string
76
278
  readonly #parentheses: Parenthesis[] = []
@@ -224,14 +426,14 @@ class TokenScanner {
224
426
  this.#position += 1
225
427
  return { category: 'template', start, end: this.#position, children }
226
428
  } else if (this.#text.startsWith('${', this.#position)) {
227
- const opening = this.#position
429
+ const offset = this.#position
228
430
  this.#position += 2
229
431
  this.#braces.push({ category: 'template', ambiguous: false })
230
432
  const expression = this.#readCode(true, depth)
231
433
  this.#braces.pop()
232
434
  children.push({
233
435
  category: 'code',
234
- start: opening,
436
+ start: offset,
235
437
  end: this.#position,
236
438
  children: expression,
237
439
  })
@@ -243,65 +445,296 @@ class TokenScanner {
243
445
  }
244
446
  }
245
447
 
246
- function readArguments(
448
+ /**
449
+ * Reads the source text a token spans.
450
+ *
451
+ * @param text - The source text.
452
+ * @param token - The token.
453
+ * @returns The token's spelling.
454
+ */
455
+ export function spell(text: string, token: Token): string {
456
+ return text.slice(token.start, token.end)
457
+ }
458
+
459
+ /**
460
+ * Checks whether a token is a comment or whitespace.
461
+ *
462
+ * @param text - The source text.
463
+ * @param token - The token.
464
+ * @returns True if the token carries no code.
465
+ */
466
+ export function isTrivia(text: string, token: Token): boolean {
467
+ return (
468
+ token.category === 'line' ||
469
+ token.category === 'block' ||
470
+ (token.category === 'code' && spell(text, token).trim() === '')
471
+ )
472
+ }
473
+
474
+ /**
475
+ * Reads the line number of a token's start.
476
+ *
477
+ * @param text - The source text.
478
+ * @param token - The token, or undefined for the start of the text.
479
+ * @returns The one-based line number.
480
+ */
481
+ export function readLine(text: string, token: Token | undefined): number {
482
+ return text.slice(0, token?.start ?? 0).split(/\r\n|\n/u).length
483
+ }
484
+
485
+ /**
486
+ * Finds the parenthesis that closes a modifier's argument group.
487
+ *
488
+ * @param text - The source text.
489
+ * @param code - The code tokens, without comments and whitespace.
490
+ * @param open - The index of the opening parenthesis.
491
+ * @param name - The modifier name, for the refusal.
492
+ * @param file - The file label, for the refusal.
493
+ * @returns The index of the matching closing parenthesis.
494
+ * @throws Thrown when the group never closes.
495
+ */
496
+ export function findGroupEnd(
247
497
  text: string,
248
- tokens: IterableIterator<Token>,
249
- location: string,
250
- depth: number,
498
+ code: readonly Token[],
499
+ open: number,
500
+ name: string,
501
+ file: string,
251
502
  ): number {
252
- if (depth > 256) throw new Error(`${location}: each argument nesting exceeds 256`)
253
- for (const token of tokens) {
254
- if (token.category !== 'code') continue
255
- const spelling = text.slice(token.start, token.end)
256
- if (spelling === '(') readArguments(text, tokens, location, depth + 1)
257
- else if (spelling === ')') return token.end
503
+ const found = code.slice(open).reduce<{ readonly depth: number; readonly end: number }>(
504
+ (state, token, offset) => {
505
+ if (state.end >= 0 || token.category !== 'code') return state
506
+ const word = spell(text, token)
507
+ const depth = state.depth + (word === '(' ? 1 : word === ')' ? -1 : 0)
508
+ return { depth, end: word === ')' && depth === 0 ? open + offset : -1 }
509
+ },
510
+ { depth: 0, end: -1 },
511
+ )
512
+ if (found.end < 0) {
513
+ throw new Error(`${file}:${readLine(text, code[open])}: Unbalanced ${name} arguments`)
258
514
  }
259
- throw new Error(`${location}: Unbalanced each arguments`)
260
- }
261
-
262
- function readRegistrations(text: string, tokens: readonly Token[], file: string): readonly Token[] {
263
- const code = tokens
264
- .map((token) => {
265
- const raw = text.slice(token.start, token.end)
266
- if (token.category === 'code') return raw
267
- const blank = raw
268
- .split('')
269
- .map((character) => (/[\r\n]/u.test(character) ? character : ' '))
270
- .join('')
271
- return token.category === 'string' || token.category === 'template'
272
- ? `\0${blank.slice(1)}`
273
- : blank
274
- })
275
- .join('')
276
- const offset = tokens[0]?.start ?? 0
277
- const positions = new Map(tokens.map((token, index) => [token.start, index]))
278
- const starts = new Set(
279
- Array.from(code.matchAll(TITLE_PATTERN)).flatMap((match) => {
280
- const opening = offset + match.index + match[0].length - 1
281
- const direct = /^\s*\0/u.exec(code.slice(opening + 1 - offset))
282
- if (!/\.each\(/u.test(match[0]) && direct !== null) return [opening + direct[0].length]
283
- const index = positions.get(opening)
284
- if (index === undefined) return []
285
- const line = text.slice(0, opening).split(/\r\n|\n/u).length
286
- const end = readArguments(text, tokens.slice(index + 1).values(), `${file}:${line}`, 0)
287
- const title = /^\s*\(\s*\0/u.exec(code.slice(end - offset))
288
- return title === null ? [] : [end + title[0].length - 1]
289
- }),
515
+ return found.end
516
+ }
517
+
518
+ /**
519
+ * Finds where a registration call's first argument ends.
520
+ *
521
+ * @param text - The source text.
522
+ * @param code - The code tokens, without comments and whitespace.
523
+ * @param from - The index of the first argument's first token.
524
+ * @param file - The file label, for the refusal.
525
+ * @returns The index of the first top-level comma or closing parenthesis.
526
+ * @throws Thrown when the call never closes.
527
+ */
528
+ export function findArgumentEnd(
529
+ text: string,
530
+ code: readonly Token[],
531
+ from: number,
532
+ file: string,
533
+ ): number {
534
+ const found = code.slice(from).reduce<{ readonly depth: number; readonly end: number }>(
535
+ (state, token, offset) => {
536
+ if (state.end >= 0 || token.category !== 'code') return state
537
+ const word = spell(text, token)
538
+ if (state.depth === 0 && (word === ',' || word === ')')) {
539
+ return { depth: 0, end: from + offset }
540
+ }
541
+ return {
542
+ depth: state.depth + (OPENERS.includes(word) ? 1 : CLOSERS.includes(word) ? -1 : 0),
543
+ end: -1,
544
+ }
545
+ },
546
+ { depth: 0, end: -1 },
290
547
  )
548
+ if (found.end < 0) {
549
+ throw new Error(`${file}:${readLine(text, code[from - 1])}: Unbalanced call arguments`)
550
+ }
551
+ return found.end
552
+ }
553
+
554
+ /**
555
+ * Reads the modifier chain after a registrar, up to its registration call.
556
+ *
557
+ * @remarks
558
+ * `.each` and its tagged-template form, `.skipIf(...)`, `.runIf(...)`, and `.for(...)` are followed
559
+ * by a second call that registers the title, so the first group or template is skipped.
560
+ *
561
+ * @param text - The source text.
562
+ * @param code - The code tokens, without comments and whitespace.
563
+ * @param at - The index after the registrar or the last chain element.
564
+ * @param last - The last modifier name read; empty after a skipped group or at the start.
565
+ * @param file - The file label, for a refusal.
566
+ * @returns The index of the registration call's opening parenthesis, or -1 when the tokens are no
567
+ * registration call.
568
+ */
569
+ export function readCall(
570
+ text: string,
571
+ code: readonly Token[],
572
+ at: number,
573
+ last: string,
574
+ file: string,
575
+ ): number {
576
+ const token = code[at]
577
+ if (token === undefined) return -1
578
+ const word = token.category === 'code' ? spell(text, token) : ''
579
+ if (word === '.') {
580
+ const name = code[at + 1]
581
+ const spelled = name?.category === 'code' ? spell(text, name) : ''
582
+ return IDENTIFIER_PATTERN.test(spelled) ? readCall(text, code, at + 2, spelled, file) : -1
583
+ }
584
+ if (token.category === 'template' && ARGUMENT_MODIFIERS.has(last)) {
585
+ return readCall(text, code, at + 1, '', file)
586
+ }
587
+ if (word !== '(') return -1
588
+ if (!ARGUMENT_MODIFIERS.has(last)) return at
589
+ return readCall(text, code, findGroupEnd(text, code, at, last, file) + 1, '', file)
590
+ }
591
+
592
+ /**
593
+ * Checks whether the code token at an index opens a test registration.
594
+ *
595
+ * @param text - The source text.
596
+ * @param code - The code tokens, without comments and whitespace.
597
+ * @param index - The token index.
598
+ * @returns True if the token is a registrar word that no member access precedes.
599
+ */
600
+ export function isRegistrar(text: string, code: readonly Token[], index: number): boolean {
601
+ const token = code[index]
602
+ if (token?.category !== 'code' || !REGISTRARS.has(spell(text, token))) return false
603
+ const previous = code[index - 1]
604
+ return !(previous?.category === 'code' && spell(text, previous) === '.')
605
+ }
606
+
607
+ /**
608
+ * Reads the registration a registrar token starts.
609
+ *
610
+ * @param text - The source text.
611
+ * @param code - The code tokens, without comments and whitespace.
612
+ * @param index - The registrar's index.
613
+ * @param file - The file label, for a refusal.
614
+ * @returns One registration, or none when the registrar starts no call.
615
+ */
616
+ export function readRegistration(
617
+ text: string,
618
+ code: readonly Token[],
619
+ index: number,
620
+ file: string,
621
+ ): readonly Registration[] {
622
+ const call = readCall(text, code, index + 1, '', file)
623
+ if (call < 0) return []
624
+ const first = code.slice(call + 1, findArgumentEnd(text, code, call + 1, file))
625
+ const only = first.length === 1 ? first[0] : undefined
626
+ if (only !== undefined && (only.category === 'string' || only.category === 'template')) {
627
+ return [{ token: only, spelling: spell(text, only) }]
628
+ }
629
+ const head = first[0]
630
+ const tail = first.at(-1)
291
631
  return [
292
- ...tokens.filter((token) => starts.has(token.start)),
293
- ...tokens.flatMap((token) => readRegistrations(text, token.children, file)),
632
+ {
633
+ token: undefined,
634
+ spelling:
635
+ head === undefined || tail === undefined
636
+ ? ''
637
+ : text.slice(head.start, tail.end).replace(/\s+/gu, ' '),
638
+ },
294
639
  ]
295
640
  }
296
641
 
297
- function collectLiterals(tokens: readonly Token[]): readonly Token[] {
642
+ /**
643
+ * Reads every test registration in a token list and in the template expressions nested in it.
644
+ *
645
+ * @param text - The source text.
646
+ * @param tokens - The scanned tokens.
647
+ * @param file - The file label, for a refusal.
648
+ * @returns The registrations in token order, then those nested in template expressions.
649
+ */
650
+ export function readRegistrations(
651
+ text: string,
652
+ tokens: readonly Token[],
653
+ file: string,
654
+ ): readonly Registration[] {
655
+ const code = tokens.filter((token) => !isTrivia(text, token))
656
+ const here = code.flatMap((_token, index) =>
657
+ isRegistrar(text, code, index) ? readRegistration(text, code, index, file) : [],
658
+ )
659
+ return [...here, ...tokens.flatMap((token) => readRegistrations(text, token.children, file))]
660
+ }
661
+
662
+ /**
663
+ * Collects the string and template tokens of a token tree.
664
+ *
665
+ * @param tokens - The scanned tokens.
666
+ * @returns Every string or template token, nested ones included.
667
+ */
668
+ export function collectLiterals(tokens: readonly Token[]): readonly Token[] {
298
669
  return tokens.flatMap((token) => [
299
670
  ...(token.category === 'string' || token.category === 'template' ? [token] : []),
300
671
  ...collectLiterals(token.children),
301
672
  ])
302
673
  }
303
674
 
304
- function readGit(root: string, args: readonly string[]): string {
675
+ /**
676
+ * Lists the registrations of a tree that have no literal title.
677
+ *
678
+ * @param file - The file the registrations came from.
679
+ * @param registrations - The file's registrations.
680
+ * @param tree - The tree the file was read from.
681
+ * @returns One dynamic row per registration without a title token.
682
+ */
683
+ export function readDynamic(
684
+ file: string,
685
+ registrations: readonly Registration[],
686
+ tree: Dynamic['tree'],
687
+ ): readonly Dynamic[] {
688
+ return registrations
689
+ .filter((registration) => registration.token === undefined)
690
+ .map((registration) => ({ file, spelling: registration.spelling, tree }))
691
+ }
692
+
693
+ /**
694
+ * Counts the registrations that have no literal title.
695
+ *
696
+ * @param registrations - A file's registrations.
697
+ * @returns How many have no title token.
698
+ */
699
+ export function countDynamic(registrations: readonly Registration[]): number {
700
+ return registrations.filter((registration) => registration.token === undefined).length
701
+ }
702
+
703
+ /**
704
+ * Lists the files that register fewer non-literal titles than the baseline did.
705
+ *
706
+ * @param baseline - Each baseline test file with its registrations.
707
+ * @param current - Each working test file with its registrations.
708
+ * @returns One row per baseline file whose count of non-literal registrations dropped, with the
709
+ * drop; a file absent from the working tree counts none.
710
+ */
711
+ export function readLostDynamic(
712
+ baseline: ReadonlyArray<{
713
+ readonly file: string
714
+ readonly registrations: readonly Registration[]
715
+ }>,
716
+ current: ReadonlyArray<{
717
+ readonly file: string
718
+ readonly registrations: readonly Registration[]
719
+ }>,
720
+ ): readonly LostDynamic[] {
721
+ return baseline.flatMap(({ file, registrations }) => {
722
+ const before = countDynamic(registrations)
723
+ const now = current.find((entry) => entry.file === file)
724
+ const after = now === undefined ? 0 : countDynamic(now.registrations)
725
+ return before > after ? [{ file, deficit: before - after }] : []
726
+ })
727
+ }
728
+
729
+ /**
730
+ * Runs git in a directory and returns its output.
731
+ *
732
+ * @param root - The directory to run in.
733
+ * @param args - The git arguments.
734
+ * @returns The standard output.
735
+ * @throws Thrown when git cannot run or exits nonzero.
736
+ */
737
+ export function readGit(root: string, args: readonly string[]): string {
305
738
  const result = spawnSync('git', ['-C', root, ...args], {
306
739
  encoding: 'utf8',
307
740
  windowsHide: true,
@@ -313,21 +746,33 @@ function readGit(root: string, args: readonly string[]): string {
313
746
  return result.stdout
314
747
  }
315
748
 
316
- function readReport(base: string, root: string): Report {
317
- const baseline = readGit(root, ['ls-tree', '-r', '-z', '--name-only', base, '--', 'tests'])
749
+ /**
750
+ * Compares the titles of the working tree with the baseline revision.
751
+ *
752
+ * @param base - The revision to compare against.
753
+ * @param root - The directory whose `tests/` tree is compared; it may sit below the repository top.
754
+ * @returns The missing, moved, and dynamic rows with the baseline title count.
755
+ * @throws Thrown when git refuses, a file cannot be read, or a token cannot be classified.
756
+ */
757
+ export function readReport(base: string, root: string): Report {
758
+ const tree = readGit(root, ['rev-parse', '--verify', '--end-of-options', `${base}^{tree}`]).trim()
759
+ const baselineFiles = readGit(root, ['ls-tree', '-r', '-z', '--name-only', tree, '--', 'tests'])
318
760
  .split('\0')
319
761
  .filter((file) => file.endsWith('.test.ts'))
320
- .flatMap((file) => {
321
- const text = readGit(root, ['show', `${base}:${file}`])
322
- return readRegistrations(
323
- text,
324
- new TokenScanner(text, `${base}:${file}`).scan(),
325
- `${base}:${file}`,
326
- ).map((token) => ({
762
+ .map((file) => {
763
+ const text = readGit(root, ['show', `${tree}:./${file}`])
764
+ const label = `${base}:${file}`
765
+ return {
327
766
  file,
328
- title: decodeTitle(text, token),
329
- }))
767
+ text,
768
+ registrations: readRegistrations(text, new TokenScanner(text, label).scan(), label),
769
+ }
330
770
  })
771
+ const baseline = baselineFiles.flatMap(({ file, text, registrations }) =>
772
+ registrations.flatMap(({ token }) =>
773
+ token === undefined ? [] : [{ file, title: decodeTitle(text, token) }],
774
+ ),
775
+ )
331
776
  const directory = join(root, 'tests')
332
777
  const files = (existsSync(directory) ? listFiles(directory) : [])
333
778
  .filter(({ path }) => path.endsWith('.ts'))
@@ -346,14 +791,19 @@ function readReport(base: string, root: string): Report {
346
791
  const titles = files
347
792
  .filter(({ file }) => file.endsWith('.test.ts'))
348
793
  .flatMap(({ file, text, registrations }) =>
349
- registrations.map((token) => ({ file, title: decodeTitle(text, token) })),
794
+ registrations.flatMap(({ token }) =>
795
+ token === undefined ? [] : [{ file, title: decodeTitle(text, token) }],
796
+ ),
350
797
  )
351
798
  // A surviving registration supplies its title count, never evidence that a lost duplicate moved.
352
- const holders = files.flatMap(({ file, text, tokens, registrations }) =>
353
- collectLiterals(tokens)
354
- .filter((token) => !registrations.includes(token))
355
- .map((token) => ({ file, title: decodeTitle(text, token) })),
356
- )
799
+ const holders = files.flatMap(({ file, text, tokens, registrations }) => {
800
+ const registered = new Set(
801
+ registrations.flatMap(({ token }) => (token === undefined ? [] : [token])),
802
+ )
803
+ return collectLiterals(tokens)
804
+ .filter((token) => !registered.has(token))
805
+ .map((token) => ({ file, title: decodeTitle(text, token) }))
806
+ })
357
807
  const deficits = [...Map.groupBy(baseline, ({ title }) => title)].flatMap(([title, entries]) => {
358
808
  const survivors = titles.filter((candidate) => candidate.title === title)
359
809
  const unmatched = [...Map.groupBy(entries, ({ file }) => file)].flatMap(([file, local]) =>
@@ -382,18 +832,65 @@ function readReport(base: string, root: string): Report {
382
832
  moved: classified.flatMap(({ entry, holder }) =>
383
833
  holder === undefined ? [] : [{ file: entry.file, title: entry.title, holder }],
384
834
  ),
835
+ dynamic: [
836
+ ...baselineFiles.flatMap(({ file, registrations }) =>
837
+ readDynamic(file, registrations, 'baseline'),
838
+ ),
839
+ ...files.flatMap(({ file, registrations }) => readDynamic(file, registrations, 'working')),
840
+ ],
841
+ lostDynamic: readLostDynamic(baselineFiles, files),
385
842
  }
386
843
  }
387
844
 
388
- function renderReport(report: Report): string {
845
+ /**
846
+ * Escapes the tab, carriage return, and newline characters of a text-row cell.
847
+ *
848
+ * @param cell - The cell text.
849
+ * @returns The cell with each of those characters written as a backslash sequence.
850
+ */
851
+ export function escapeCell(cell: string): string {
852
+ return cell.replace(/\t/gu, '\\t').replace(/\r/gu, '\\r').replace(/\n/gu, '\\n')
853
+ }
854
+
855
+ /**
856
+ * Renders a report as a header line and one tab-separated row per finding.
857
+ *
858
+ * @param report - The report to render.
859
+ * @returns The text, without a trailing line feed.
860
+ */
861
+ export function renderReport(report: Report): string {
389
862
  return [
390
- `titles: ${report.count} baseline title(s) at ${report.base}, ${report.missing.length} missing, ${report.moved.length} moved`,
391
- ...report.missing.map(({ file, title, deficit }) => `missing\t${file}\t${title}\t${deficit}`),
392
- ...report.moved.map(({ file, title, holder }) => `moved\t${file}\t${title}\t${holder}`),
863
+ `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`,
864
+ ...report.missing.map(({ file, title, deficit }) =>
865
+ ['missing', file, title, String(deficit)].map(escapeCell).join('\t'),
866
+ ),
867
+ ...report.moved.map(({ file, title, holder }) =>
868
+ ['moved', file, title, holder].map(escapeCell).join('\t'),
869
+ ),
870
+ ...report.dynamic.map(({ file, spelling, tree }) =>
871
+ ['dynamic', file, tree, spelling].map(escapeCell).join('\t'),
872
+ ),
873
+ ...report.lostDynamic.map(({ file, deficit }) =>
874
+ ['lost-dynamic', file, String(deficit)].map(escapeCell).join('\t'),
875
+ ),
393
876
  ].join('\n')
394
877
  }
395
878
 
396
- function main(argv: readonly string[]): number {
879
+ /**
880
+ * Runs the comparison and prints its report.
881
+ *
882
+ * @param argv - The arguments after the script path.
883
+ * @returns The exit code: 0 clean, 3 a missing or moved title or a lost-dynamic file, 2 a tool
884
+ * failure, 64 usage.
885
+ */
886
+ export function main(argv: readonly string[]): number {
887
+ const unknown = readUnknownFlags(argv, FLAGS)
888
+ if (unknown.length > 0) {
889
+ console.error(
890
+ `titles: ${unknown.map(describeName).join(', ')} is not a flag this script reads; give each value as its own argument. ${USAGE}`,
891
+ )
892
+ return 64
893
+ }
397
894
  const missing = readMissingFlags(argv, ['--base', '--root', '--out'])
398
895
  if (missing.length > 0) {
399
896
  console.error(`titles: ${missing.join(', ')} given with no value`)
@@ -402,7 +899,13 @@ function main(argv: readonly string[]): number {
402
899
  const base = readOption(argv, '--base')
403
900
  const out = readOption(argv, '--out')
404
901
  if (base === undefined || (out !== undefined && !['.json', '.txt'].includes(extname(out)))) {
405
- console.error('usage: titles.ts --base REV [--root DIR] [--json] [--out FILE.json|FILE.txt]')
902
+ console.error(USAGE)
903
+ return 64
904
+ }
905
+ if (base.startsWith('-')) {
906
+ console.error(
907
+ `titles: --base ${describeName(base)} opens with "-"; give a revision name or hash`,
908
+ )
406
909
  return 64
407
910
  }
408
911
  try {
@@ -415,11 +918,13 @@ function main(argv: readonly string[]): number {
415
918
  writeFileSync(target, `${extname(target) === '.json' ? json : text}\n`)
416
919
  }
417
920
  console.log(argv.includes('--json') ? json : text)
418
- return report.missing.length > 0 ? 3 : 0
921
+ return report.missing.length + report.moved.length + report.lostDynamic.length > 0 ? 3 : 0
419
922
  } catch (error) {
420
923
  console.error(`titles: ${error instanceof Error ? error.message : String(error)}`)
421
924
  return 2
422
925
  }
423
926
  }
424
927
 
425
- process.exitCode = main(process.argv.slice(2))
928
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
929
+ process.exitCode = main(process.argv.slice(2))
930
+ }