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