@ticket-gate/engine 0.0.0-stage → 0.1.0-rc.1
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/LICENSE +202 -0
- package/README.md +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- package/tree.mjs +211 -0
package/precheck.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { firstError } from './summary.mjs';
|
|
3
|
+
import { registryHooks } from './mutations/gate.mjs';
|
|
4
|
+
import { commandsOf } from './config.mjs';
|
|
5
|
+
import { GATE } from './project.mjs';
|
|
6
|
+
import { ROOT } from './root.mjs';
|
|
7
|
+
|
|
8
|
+
const precheckIo = {
|
|
9
|
+
run(cmd) {
|
|
10
|
+
const r = spawnSync('bash', ['-c', cmd], {
|
|
11
|
+
cwd: ROOT,
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
14
|
+
});
|
|
15
|
+
return { code: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
|
16
|
+
},
|
|
17
|
+
log: (line) => console.log(line),
|
|
18
|
+
branch: () =>
|
|
19
|
+
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
20
|
+
cwd: ROOT,
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
}).trim(),
|
|
23
|
+
registry: () => registryHooks(GATE)?.entries?.() ?? new Map(),
|
|
24
|
+
baseRegistry: (base) => registryHooks(GATE)?.entriesAt?.(base) ?? new Map(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** `--ticket=ID` from an argv, else undefined. */
|
|
28
|
+
const ticketArg = (argv) =>
|
|
29
|
+
argv.find((a) => a.startsWith('--ticket='))?.slice('--ticket='.length) ||
|
|
30
|
+
undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `ticket-gate verify --precheck`: the checks that most often turn a green wave
|
|
34
|
+
* red, in about two minutes, without a stack. Writes nothing under .turbo/ and
|
|
35
|
+
* is never a gate result. Returns the exit code.
|
|
36
|
+
*/
|
|
37
|
+
export async function runPrecheck(
|
|
38
|
+
{ base, ticket = ticketArg(process.argv.slice(2)) },
|
|
39
|
+
io = precheckIo,
|
|
40
|
+
) {
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
const live = (reg) =>
|
|
43
|
+
new Set(
|
|
44
|
+
[...reg.entries()]
|
|
45
|
+
.filter(([, v]) => !v.entry?.retired)
|
|
46
|
+
.map(([k]) => k.split('/')[0]),
|
|
47
|
+
);
|
|
48
|
+
let baseKeys = new Set();
|
|
49
|
+
try {
|
|
50
|
+
baseKeys = new Set(io.baseRegistry(base).keys());
|
|
51
|
+
} catch {
|
|
52
|
+
baseKeys = new Set();
|
|
53
|
+
}
|
|
54
|
+
const reg = io.registry();
|
|
55
|
+
const added = [...reg.keys()]
|
|
56
|
+
.filter((k) => !baseKeys.has(k))
|
|
57
|
+
.map((k) => k.split('/')[0]);
|
|
58
|
+
const fromBranch =
|
|
59
|
+
registryHooks(GATE)?.ticketFromBranch?.(io.branch()) ?? null;
|
|
60
|
+
const known = live(reg);
|
|
61
|
+
const ids = [
|
|
62
|
+
...new Set(
|
|
63
|
+
ticket ? [ticket] : [...(fromBranch ? [fromBranch] : []), ...added],
|
|
64
|
+
),
|
|
65
|
+
].filter((id) => known.has(id));
|
|
66
|
+
const named = ticket ?? fromBranch ?? ids[0] ?? '<ID>';
|
|
67
|
+
const ctx = { base, ids, named };
|
|
68
|
+
const checks = GATE.precheck.flatMap((c) =>
|
|
69
|
+
(typeof c === 'function' ? c(ctx) : [c]).map((e) => ({
|
|
70
|
+
id: e.id,
|
|
71
|
+
cmd: typeof e.cmd === 'function' ? e.cmd({ base }) : e.cmd,
|
|
72
|
+
fix: e.fix.replace('<ID>', named),
|
|
73
|
+
})),
|
|
74
|
+
);
|
|
75
|
+
const failed = [];
|
|
76
|
+
for (const c of checks) {
|
|
77
|
+
const r = io.run(c.cmd);
|
|
78
|
+
if (r.code === 0) {
|
|
79
|
+
io.log(`precheck ${c.id}: ok`);
|
|
80
|
+
} else {
|
|
81
|
+
failed.push(c.id);
|
|
82
|
+
io.log(`precheck ${c.id}: FAIL ${firstError(r.out)}. Fix: ${c.fix}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
86
|
+
io.log(
|
|
87
|
+
`precheck: ${checks.length - failed.length} ok, ${failed.length} failed (${secs}s). Not a gate result: run ${commandsOf(GATE).verify}.`,
|
|
88
|
+
);
|
|
89
|
+
return failed.length > 0 ? 1 : 0;
|
|
90
|
+
}
|
package/project.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The project's gate config, loaded once from the project root. Top-level await: no module in
|
|
2
|
+
// gate.config.mjs's own static import graph (config.mjs, root.mjs, tree.mjs, the pack's index,
|
|
3
|
+
// units, steps and stack modules, the project tools the config imports) may import this file.
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { ROOT } from './root.mjs';
|
|
8
|
+
|
|
9
|
+
const load = (root) =>
|
|
10
|
+
import(pathToFileURL(join(root, 'gate.config.mjs')).href);
|
|
11
|
+
|
|
12
|
+
// TICKET_GATE_CONFIG_DIR, when set, names the directory whose gate.config.mjs is loaded instead
|
|
13
|
+
// of the project root's (resolved against the working directory). ROOT itself is unchanged.
|
|
14
|
+
const CONFIG_DIR = process.env.TICKET_GATE_CONFIG_DIR
|
|
15
|
+
? resolve(process.cwd(), process.env.TICKET_GATE_CONFIG_DIR)
|
|
16
|
+
: ROOT;
|
|
17
|
+
|
|
18
|
+
export { ROOT };
|
|
19
|
+
/** The config module's namespace: its default export and any extra named exports. */
|
|
20
|
+
export const CONFIG_EXPORTS = await load(CONFIG_DIR);
|
|
21
|
+
export const GATE = CONFIG_EXPORTS.default;
|
package/ratchet.mjs
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Generic ratchets for `ticket-gate ratchet <name>`: a line-count baseline and a skill-reference
|
|
2
|
+
// resolver. Every path, limit, message and command name arrives through the factory argument;
|
|
3
|
+
// the project values live in the project's gate config.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export function countLines(text) {
|
|
15
|
+
return text === ''
|
|
16
|
+
? 0
|
|
17
|
+
: text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const isDir = (p) => {
|
|
21
|
+
try {
|
|
22
|
+
return statSync(p).isDirectory();
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A module-size ratchet bound to `cfg`: { limits: { module, test }, testRe, roots, baselineRel,
|
|
30
|
+
* comment, name, cmd, fixNew, fixGrew, usage }. `name` prefixes every message, `cmd` is the
|
|
31
|
+
* command a message tells the reader to run.
|
|
32
|
+
*/
|
|
33
|
+
export function createModuleSize(cfg) {
|
|
34
|
+
const { limits, testRe, roots, name, cmd } = cfg;
|
|
35
|
+
const limitOf = (path) => (testRe.test(path) ? limits.test : limits.module);
|
|
36
|
+
|
|
37
|
+
function trackedFiles(root) {
|
|
38
|
+
const r = spawnSync('git', ['ls-files', '-z', '--', ...roots], {
|
|
39
|
+
cwd: root,
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
});
|
|
42
|
+
if (r.status !== 0)
|
|
43
|
+
throw new Error(
|
|
44
|
+
`git ls-files failed in ${root}: ${String(r.stderr).trim()}. Run it inside a git work tree.`,
|
|
45
|
+
);
|
|
46
|
+
return r.stdout
|
|
47
|
+
.split('\0')
|
|
48
|
+
.filter((f) => /\.m?js$/.test(f))
|
|
49
|
+
.sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function measure(root, files) {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
const p = join(root, f);
|
|
56
|
+
if (existsSync(p)) out[f] = countLines(readFileSync(p, 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const over = (path, n) => ({ path, lines: n, limit: limitOf(path) });
|
|
62
|
+
const less = (path, e, now) => ({ path, was: e.lines, now: now ?? 0 });
|
|
63
|
+
|
|
64
|
+
function compare(sizes, baseline) {
|
|
65
|
+
const fresh = [];
|
|
66
|
+
const grown = [];
|
|
67
|
+
const shrunk = [];
|
|
68
|
+
for (const path of Object.keys(sizes)) {
|
|
69
|
+
const was = baseline.files[path]?.lines;
|
|
70
|
+
const n = sizes[path];
|
|
71
|
+
if (!was && n > limitOf(path)) fresh.push(over(path, n));
|
|
72
|
+
if (was && n > was) grown.push({ path, was, now: n });
|
|
73
|
+
}
|
|
74
|
+
for (const [path, e] of Object.entries(baseline.files)) {
|
|
75
|
+
const now = sizes[path];
|
|
76
|
+
if (now === undefined || now < e.lines) shrunk.push(less(path, e, now));
|
|
77
|
+
}
|
|
78
|
+
return { fresh, grown, shrunk };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function updateBaseline(sizes, baseline, { allow = {}, seed = false } = {}) {
|
|
82
|
+
const files = {};
|
|
83
|
+
const added = [];
|
|
84
|
+
const lowered = [];
|
|
85
|
+
const removed = [];
|
|
86
|
+
const refused = [];
|
|
87
|
+
for (const path of Object.keys(sizes).sort()) {
|
|
88
|
+
const n = sizes[path];
|
|
89
|
+
if (n <= limitOf(path)) continue;
|
|
90
|
+
const prev = baseline.files[path];
|
|
91
|
+
if (prev && n > prev.lines && !allow[path]) {
|
|
92
|
+
refused.push({ path, why: 'grew', was: prev.lines, now: n });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!prev && !seed && !allow[path]) {
|
|
96
|
+
refused.push({ path, why: 'no allow', now: n });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const entry = { lines: n };
|
|
100
|
+
const reason = allow[path] ?? prev?.allow;
|
|
101
|
+
if (reason) entry.allow = reason;
|
|
102
|
+
files[path] = entry;
|
|
103
|
+
if (!prev) added.push(path);
|
|
104
|
+
else if (n < prev.lines) lowered.push(path);
|
|
105
|
+
}
|
|
106
|
+
for (const path of Object.keys(baseline.files))
|
|
107
|
+
if (!files[path] && !refused.some((r) => r.path === path))
|
|
108
|
+
removed.push(path);
|
|
109
|
+
return {
|
|
110
|
+
next: { comment: cfg.comment, files },
|
|
111
|
+
added,
|
|
112
|
+
lowered,
|
|
113
|
+
removed,
|
|
114
|
+
refused,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkModuleSize(root, opts = {}) {
|
|
119
|
+
const baselinePath = opts.baselinePath ?? join(root, cfg.baselineRel);
|
|
120
|
+
const seeding = Boolean(opts.update && opts.seed);
|
|
121
|
+
let baseline = { files: {} };
|
|
122
|
+
const hadBaseline = existsSync(baselinePath);
|
|
123
|
+
if (hadBaseline) {
|
|
124
|
+
try {
|
|
125
|
+
baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return {
|
|
128
|
+
code: 2,
|
|
129
|
+
wrote: false,
|
|
130
|
+
lines: [
|
|
131
|
+
`${name}: unreadable baseline ${baselinePath}: ${e.message}. Fix: resolve the conflict, or delete the file and run ${cmd} --update --seed.`,
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
!baseline ||
|
|
137
|
+
typeof baseline.files !== 'object' ||
|
|
138
|
+
baseline.files === null
|
|
139
|
+
)
|
|
140
|
+
return {
|
|
141
|
+
code: 2,
|
|
142
|
+
wrote: false,
|
|
143
|
+
lines: [
|
|
144
|
+
`${name}: baseline ${baselinePath} has no "files" object. Fix: resolve the conflict, or delete the file and run ${cmd} --update --seed.`,
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
} else if (!seeding) {
|
|
148
|
+
return {
|
|
149
|
+
code: 2,
|
|
150
|
+
wrote: false,
|
|
151
|
+
lines: [
|
|
152
|
+
`${name}: no baseline at ${baselinePath}. Fix: ${cmd} --update --seed.`,
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
let files;
|
|
157
|
+
try {
|
|
158
|
+
files = trackedFiles(root);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { code: 2, wrote: false, lines: [`${name}: ${e.message}`] };
|
|
161
|
+
}
|
|
162
|
+
const sizes = measure(root, files);
|
|
163
|
+
|
|
164
|
+
if (opts.update) {
|
|
165
|
+
const u = updateBaseline(sizes, baseline, {
|
|
166
|
+
allow: opts.allow,
|
|
167
|
+
seed: opts.seed && !hadBaseline,
|
|
168
|
+
});
|
|
169
|
+
if (u.refused.length > 0) {
|
|
170
|
+
return {
|
|
171
|
+
code: 1,
|
|
172
|
+
wrote: false,
|
|
173
|
+
lines: [
|
|
174
|
+
...u.refused.map((r) =>
|
|
175
|
+
r.why === 'grew'
|
|
176
|
+
? `GREW ${r.path} was ${r.was}, now ${r.now}: shrink it, or --allow=${r.path}=<reason>`
|
|
177
|
+
: `NO REASON ${r.path} ${r.now} lines: split it, or --allow=${r.path}=<reason>`,
|
|
178
|
+
),
|
|
179
|
+
`${name}: baseline not written.`,
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
writeFileSync(baselinePath, `${JSON.stringify(u.next, null, 2)}\n`);
|
|
184
|
+
return {
|
|
185
|
+
code: 0,
|
|
186
|
+
wrote: true,
|
|
187
|
+
lines: [
|
|
188
|
+
...u.added.map((p) => `ADDED ${p} ${sizes[p]}`),
|
|
189
|
+
...u.lowered.map(
|
|
190
|
+
(p) => `LOWERED ${p} ${baseline.files[p].lines} -> ${sizes[p]}`,
|
|
191
|
+
),
|
|
192
|
+
...u.removed.map((p) => `REMOVED ${p}`),
|
|
193
|
+
`${name}: baseline updated (${Object.keys(u.next.files).length} entries)`,
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const c = compare(sizes, baseline);
|
|
199
|
+
const notes = c.shrunk.map(
|
|
200
|
+
(s) =>
|
|
201
|
+
`SHRUNK ${s.path} was ${s.was}, now ${s.now}: run ${cmd} --update to lock it in`,
|
|
202
|
+
);
|
|
203
|
+
if (c.fresh.length === 0 && c.grown.length === 0) {
|
|
204
|
+
return {
|
|
205
|
+
code: 0,
|
|
206
|
+
wrote: false,
|
|
207
|
+
lines: [
|
|
208
|
+
...notes,
|
|
209
|
+
`${name}: ${Object.keys(sizes).length} files measured, ${Object.keys(baseline.files).length} baselined, none grew`,
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
code: 1,
|
|
215
|
+
wrote: false,
|
|
216
|
+
lines: [
|
|
217
|
+
...c.fresh.map(
|
|
218
|
+
(f) => `NEW ${f.path} ${f.lines} lines (limit ${f.limit})`,
|
|
219
|
+
),
|
|
220
|
+
...c.grown.map((g) => `GREW ${g.path} was ${g.was}, now ${g.now}`),
|
|
221
|
+
...notes,
|
|
222
|
+
...(c.fresh.length > 0 ? [cfg.fixNew] : []),
|
|
223
|
+
...(c.grown.length > 0 ? [cfg.fixGrew] : []),
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseArgs(argv) {
|
|
229
|
+
const opts = { allow: {} };
|
|
230
|
+
let root = null;
|
|
231
|
+
const bad = (a) => ({
|
|
232
|
+
error: `unknown or invalid argument ${a}. ${cfg.usage}`,
|
|
233
|
+
});
|
|
234
|
+
for (const a of argv) {
|
|
235
|
+
if (a === '--update') opts.update = true;
|
|
236
|
+
else if (a === '--seed') opts.seed = true;
|
|
237
|
+
else if (a.startsWith('--root=')) root = a.slice(7);
|
|
238
|
+
else if (a.startsWith('--baseline=')) opts.baselinePath = a.slice(11);
|
|
239
|
+
else if (a.startsWith('--allow=')) {
|
|
240
|
+
const m = /^--allow=(.+?\.m?js)=(.+)$/.exec(a);
|
|
241
|
+
if (!m) return bad(a);
|
|
242
|
+
opts.allow[m[1]] = m[2];
|
|
243
|
+
} else return bad(a);
|
|
244
|
+
}
|
|
245
|
+
if ((opts.seed || Object.keys(opts.allow).length > 0) && !opts.update)
|
|
246
|
+
return { error: `--seed and --allow need --update. ${cfg.usage}` };
|
|
247
|
+
return { opts, root };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The ratchet CLI as a function: `args` are its flags, `--root=` overrides `root`. */
|
|
251
|
+
function runModuleSize(root, args) {
|
|
252
|
+
const parsed = parseArgs(args);
|
|
253
|
+
if (parsed.error) return { code: 2, lines: [`${name}: ${parsed.error}`] };
|
|
254
|
+
const { code, lines } = checkModuleSize(parsed.root ?? root, parsed.opts);
|
|
255
|
+
return { code, lines };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
limits,
|
|
260
|
+
limitOf,
|
|
261
|
+
trackedFiles,
|
|
262
|
+
measure,
|
|
263
|
+
compare,
|
|
264
|
+
updateBaseline,
|
|
265
|
+
checkModuleSize,
|
|
266
|
+
parseArgs,
|
|
267
|
+
runModuleSize,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const NAME = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
|
272
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
273
|
+
|
|
274
|
+
/** Every SKILL.md under `dir` (posix `rel` prefix), skipping node_modules. */
|
|
275
|
+
export function skillMdFiles(dir, rel, out) {
|
|
276
|
+
if (!isDir(dir)) return;
|
|
277
|
+
for (const e of readdirSync(dir).sort()) {
|
|
278
|
+
if (e === 'node_modules') continue;
|
|
279
|
+
const p = join(dir, e);
|
|
280
|
+
if (isDir(p)) skillMdFiles(p, `${rel}/${e}`, out);
|
|
281
|
+
else if (e === 'SKILL.md') out.push(`${rel}/${e}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const unquote = (s) => s.trim().replace(/^['"]|['"]$/g, '');
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A skill-reference ratchet bound to `cfg`: { prefixes, skillRoots, sourceFiles(root), name,
|
|
289
|
+
* prefixedNote, fix, usage }. `skillRoots` are posix dirs; `sourceFiles` lists the prose files to scan.
|
|
290
|
+
*/
|
|
291
|
+
export function createSkillRefs(cfg) {
|
|
292
|
+
const { prefixes, skillRoots, name } = cfg;
|
|
293
|
+
const pathRe = new RegExp(
|
|
294
|
+
`(${skillRoots.map(escape).join('|')})/(${NAME})`,
|
|
295
|
+
'g',
|
|
296
|
+
);
|
|
297
|
+
const prefixRe = new RegExp(`\\b(${prefixes.join('|')}):(${NAME})`, 'g');
|
|
298
|
+
const entryRe = new RegExp(`^(?:(${prefixes.join('|')}):)?(${NAME})$`);
|
|
299
|
+
|
|
300
|
+
function installedSkills(root) {
|
|
301
|
+
const out = new Set();
|
|
302
|
+
for (const r of skillRoots) {
|
|
303
|
+
const dir = join(root, r);
|
|
304
|
+
if (!isDir(dir)) continue;
|
|
305
|
+
for (const e of readdirSync(dir)) if (isDir(join(dir, e))) out.add(e);
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function frontmatterSkills(lines) {
|
|
311
|
+
const out = [];
|
|
312
|
+
if (lines[0]?.trim() !== '---') return out;
|
|
313
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
314
|
+
if (end < 0) return out;
|
|
315
|
+
const push = (raw, line) => {
|
|
316
|
+
const item = unquote(raw);
|
|
317
|
+
if (!item) return;
|
|
318
|
+
const m = entryRe.exec(item);
|
|
319
|
+
out.push({
|
|
320
|
+
name: m ? m[2] : item,
|
|
321
|
+
prefix: m?.[1] ?? null,
|
|
322
|
+
form: 'skills',
|
|
323
|
+
line,
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
for (let i = 1; i < end; i++) {
|
|
327
|
+
const m = /^skills:\s*(.*)$/.exec(lines[i]);
|
|
328
|
+
if (!m) continue;
|
|
329
|
+
const rest = m[1].trim();
|
|
330
|
+
if (rest) {
|
|
331
|
+
for (const part of rest.replace(/^\[|\]$/g, '').split(','))
|
|
332
|
+
push(part, i + 1);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
for (let j = i + 1; j < end; j++) {
|
|
336
|
+
const b = /^\s*-\s*(.+)$/.exec(lines[j]);
|
|
337
|
+
if (!b) break;
|
|
338
|
+
push(b[1], j + 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function skillRefs(text) {
|
|
345
|
+
const lines = text.split(/\r?\n/);
|
|
346
|
+
const refs = frontmatterSkills(lines);
|
|
347
|
+
const seen = new Set(
|
|
348
|
+
refs.map((r) => `${r.line}:${r.prefix ?? ''}:${r.name}`),
|
|
349
|
+
);
|
|
350
|
+
const add = (r) => {
|
|
351
|
+
const k = `${r.line}:${r.prefix ?? ''}:${r.name}`;
|
|
352
|
+
if (seen.has(k)) return;
|
|
353
|
+
seen.add(k);
|
|
354
|
+
refs.push(r);
|
|
355
|
+
};
|
|
356
|
+
lines.forEach((l, i) => {
|
|
357
|
+
for (const m of l.matchAll(pathRe))
|
|
358
|
+
add({
|
|
359
|
+
name: m[2],
|
|
360
|
+
prefix: null,
|
|
361
|
+
form: 'path',
|
|
362
|
+
root: m[1],
|
|
363
|
+
line: i + 1,
|
|
364
|
+
});
|
|
365
|
+
for (const m of l.matchAll(prefixRe))
|
|
366
|
+
add({ name: m[2], prefix: m[1], form: 'skills', line: i + 1 });
|
|
367
|
+
});
|
|
368
|
+
return refs.sort((a, b) => a.line - b.line);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function resolves(ref, installed, root) {
|
|
372
|
+
if (ref.prefix) return false;
|
|
373
|
+
if (ref.form === 'path') return isDir(join(root, ref.root, ref.name));
|
|
374
|
+
return installed.has(ref.name);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const written = (r) =>
|
|
378
|
+
r.prefix
|
|
379
|
+
? `${r.prefix}:${r.name}`
|
|
380
|
+
: r.form === 'path'
|
|
381
|
+
? `${r.root}/${r.name}`
|
|
382
|
+
: r.name;
|
|
383
|
+
|
|
384
|
+
function checkSkillRefs(root) {
|
|
385
|
+
const installed = installedSkills(root);
|
|
386
|
+
const files = cfg.sourceFiles(root);
|
|
387
|
+
const bad = [];
|
|
388
|
+
let total = 0;
|
|
389
|
+
for (const f of files) {
|
|
390
|
+
for (const r of skillRefs(readFileSync(join(root, f), 'utf8'))) {
|
|
391
|
+
total++;
|
|
392
|
+
if (!resolves(r, installed, root))
|
|
393
|
+
bad.push(
|
|
394
|
+
`DANGLING ${f}:${r.line} ${written(r)}${r.prefix ? cfg.prefixedNote : ''}`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (bad.length === 0)
|
|
399
|
+
return {
|
|
400
|
+
code: 0,
|
|
401
|
+
lines: [
|
|
402
|
+
`${name}: ${total} references in ${files.length} files resolve`,
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
return { code: 1, lines: [...bad, cfg.fix] };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The ratchet CLI as a function: `args` are its flags, `--root=` overrides `root`. */
|
|
409
|
+
function runSkillRefs(root, args) {
|
|
410
|
+
let at = root;
|
|
411
|
+
for (const a of args) {
|
|
412
|
+
if (!a.startsWith('--root='))
|
|
413
|
+
return {
|
|
414
|
+
code: 2,
|
|
415
|
+
lines: [`${name}: unknown argument ${a}. ${cfg.usage}`],
|
|
416
|
+
};
|
|
417
|
+
at = a.slice(7);
|
|
418
|
+
}
|
|
419
|
+
const { code, lines } = checkSkillRefs(at);
|
|
420
|
+
return { code, lines };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
prefixes,
|
|
425
|
+
installedSkills,
|
|
426
|
+
skillRefs,
|
|
427
|
+
resolves,
|
|
428
|
+
checkSkillRefs,
|
|
429
|
+
runSkillRefs,
|
|
430
|
+
};
|
|
431
|
+
}
|
package/receipt.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { VERIFIED_STATUSES } from './digest.mjs';
|
|
3
|
+
import { ROOT } from './root.mjs';
|
|
4
|
+
|
|
5
|
+
export const RECEIPT = join(ROOT, '.turbo', 'ticket-verify.json');
|
|
6
|
+
|
|
7
|
+
/** The receipt written to .turbo/ticket-verify.json. `size` is the review map's size block. */
|
|
8
|
+
export function buildReceipt({
|
|
9
|
+
treeHash,
|
|
10
|
+
ranAt,
|
|
11
|
+
ok,
|
|
12
|
+
results,
|
|
13
|
+
size,
|
|
14
|
+
base,
|
|
15
|
+
shadow = null,
|
|
16
|
+
moveProof = null,
|
|
17
|
+
}) {
|
|
18
|
+
return {
|
|
19
|
+
treeHash,
|
|
20
|
+
ranAt,
|
|
21
|
+
base,
|
|
22
|
+
ok,
|
|
23
|
+
results,
|
|
24
|
+
size: { lines: size.lines, level: size.level },
|
|
25
|
+
oversize: size.level === 'oversize',
|
|
26
|
+
shadow,
|
|
27
|
+
moveProof,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True iff every result has a verified status (pass, human, reused); an unknown status is not ok. */
|
|
32
|
+
export function receiptOk(results) {
|
|
33
|
+
return results.every((r) => VERIFIED_STATUSES.includes(r.status));
|
|
34
|
+
}
|