@orkestrel/scaffold 0.0.107 → 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.
- package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
- package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
- package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
- package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
- package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
- package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
- package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +368 -27
- package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
- package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
- package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
- package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
- package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
- package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
- package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
- package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
- package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
- package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
- package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
- package/dist/agents/templates/brief.md +1 -1
- package/dist/bin/main.js +51 -46
- package/dist/bin/main.js.map +1 -1
- package/dist/host/AGENTS.md +26 -20
- package/dist/host/agents/orchestration.md +98 -58
- package/dist/host/agents/platforms/posix.md +15 -0
- package/dist/host/agents/platforms/windows.md +37 -0
- package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
- package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
- package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
- package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
- package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
- package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
- package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
- package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +12 -14
- package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +36 -24
- package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +14 -11
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +428 -27
- package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
- package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
- package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
- package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
- package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
- package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
- package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
- package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
- package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
- package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
- package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
- package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
- package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
- package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
- package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
- package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
- package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
- package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +91 -30
- package/dist/host/agents/skills/orkestrel-parallel/agents/openai.yaml +2 -2
- package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +34 -0
- package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +29 -0
- package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +29 -0
- package/dist/host/agents/skills/orkestrel-parallel/references/t3.md +31 -0
- package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
- package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
- package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
- package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
- package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
- package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
- package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
- package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -10
- package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
- package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
- package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
- package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
- package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
- package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
- package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
- package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
- package/dist/host/agents/templates/brief.md +1 -1
- package/dist/host/agents/transports/claude-cli.md +3 -3
- package/dist/host/agents/transports/codex.md +3 -8
- package/dist/host/agents/transports/cursor.md +2 -3
- package/dist/host/claude/AGENTS.md +7 -10
- package/dist/host/claude/agents/analyst.md +3 -3
- package/dist/host/claude/agents/astra.md +3 -3
- package/dist/host/claude/agents/builder.md +1 -1
- package/dist/host/claude/agents/checker.md +1 -1
- package/dist/host/claude/agents/distiller.md +1 -1
- package/dist/host/claude/agents/grok.md +1 -1
- package/dist/host/claude/agents/opus.md +2 -2
- package/dist/host/claude/agents/orkestrel.md +57 -90
- package/dist/host/claude/agents/researcher.md +1 -1
- package/dist/host/claude/agents/reviewer.md +4 -0
- package/dist/host/claude/agents/scout.md +1 -1
- package/dist/host/claude/agents/verifier.md +1 -1
- package/dist/host/claude/rules/application.md +14 -21
- package/dist/host/claude/rules/architecture.md +34 -44
- package/dist/host/claude/rules/browser.md +3 -4
- package/dist/host/claude/rules/campaign.md +55 -0
- package/dist/host/claude/rules/documentation.md +26 -11
- package/dist/host/claude/rules/names.md +9 -4
- package/dist/host/claude/rules/patterns.md +6 -7
- package/dist/host/claude/rules/portability.md +2 -3
- package/dist/host/claude/rules/quality.md +4 -5
- package/dist/host/claude/rules/styles.md +2 -2
- package/dist/host/claude/rules/tests.md +57 -114
- package/dist/host/claude/rules/typescript.md +8 -10
- package/dist/host/claude/rules/workspace.md +21 -19
- package/dist/host/claude/rules/writing.md +3 -3
- package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-parallel/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
- package/dist/host/codex/agents/analyst.toml +9 -3
- package/dist/host/codex/agents/astra.toml +3 -4
- package/dist/host/codex/agents/builder.toml +1 -1
- package/dist/host/codex/agents/opus.toml +3 -4
- package/dist/host/codex/agents/orkestrel.toml +1 -1
- package/dist/host/codex/agents/researcher.toml +2 -2
- package/dist/host/codex/agents/verifier.toml +1 -1
- package/dist/host/codex/config.toml +3 -3
- package/dist/host/configs/helpers.ts +21 -1
- package/dist/host/configs/policy.ts +405 -16
- package/dist/host/cursor/rules/orchestration.mdc +1 -0
- package/dist/host/dotfiles/oxlintrc.json +141 -5
- package/dist/host/guides/agent.md +111 -134
- package/dist/host/guides/capture.md +801 -0
- package/dist/host/guides/probe.md +27 -8
- package/dist/host/guides/scaffold.md +152 -19
- package/dist/host/guides/study.md +534 -440
- package/dist/host/guides/test.md +111 -44
- package/dist/host/manifest.json +199 -242
- package/dist/host/tests/config.test.ts +1558 -219
- package/dist/host/tests/policy.test.ts +127 -1
- package/dist/host/tests/setupPolicy.ts +1389 -39
- package/dist/src/core/index.cjs +239 -87
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +123 -39
- package/dist/src/core/index.d.ts +123 -39
- package/dist/src/core/index.js +234 -86
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +20 -27
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.js +21 -28
- package/dist/src/server/index.js.map +1 -1
- package/package.json +11 -11
- package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
- package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
- package/dist/host/agents/skills/orkestrel-parallel/references/harnesses.md +0 -19
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
498
|
+
code: readonly Token[],
|
|
499
|
+
open: number,
|
|
500
|
+
name: string,
|
|
501
|
+
file: string,
|
|
251
502
|
): number {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
.
|
|
321
|
-
const text = readGit(root, ['show', `${
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
354
|
-
.
|
|
355
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
392
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
928
|
+
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
|
|
929
|
+
process.exitCode = main(process.argv.slice(2))
|
|
930
|
+
}
|