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