@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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +81 -2
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +19 -4
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. 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
+ }