@fantastic.dev/repo-gates 0.2.1-bootstrap.0

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.
@@ -0,0 +1,2318 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bundle-size.ts
4
+ import { spawnSync } from "child_process";
5
+ import {
6
+ existsSync,
7
+ lstatSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ rmSync,
12
+ writeFileSync
13
+ } from "fs";
14
+ import { basename, dirname, join, posix, resolve, sep } from "path";
15
+ import { gzipSync } from "zlib";
16
+ var HEADROOM = { raw: 2048, gzip: 512 };
17
+ function bucketFor(name, buckets) {
18
+ for (const [bucket, exts] of Object.entries(buckets)) {
19
+ if (exts.some((ext) => name.endsWith(ext))) return bucket;
20
+ }
21
+ return void 0;
22
+ }
23
+ function measure(distDir, buckets) {
24
+ const out = { buckets: {}, files: [] };
25
+ for (const b of Object.keys(buckets)) out.buckets[b] = { raw: 0, gzip: 0, largest: 0 };
26
+ if (!existsSync(distDir)) return out;
27
+ const walk2 = (dir, relative7) => {
28
+ for (const entry of readdirSync(dir)) {
29
+ const full = join(dir, entry);
30
+ const st = lstatSync(full);
31
+ if (st.isDirectory()) {
32
+ walk2(full, relative7 ? posix.join(relative7, entry) : entry);
33
+ continue;
34
+ }
35
+ if (!st.isFile()) continue;
36
+ const bucket = bucketFor(entry, buckets);
37
+ if (!bucket) continue;
38
+ const buf = readFileSync(full);
39
+ const raw = buf.length;
40
+ const gzip = gzipSync(buf).length;
41
+ out.files.push({ name: entry, dir: relative7, bucket, raw, gzip });
42
+ const acc = out.buckets[bucket];
43
+ if (!acc) continue;
44
+ acc.raw += raw;
45
+ acc.gzip += gzip;
46
+ if (gzip > acc.largest) acc.largest = gzip;
47
+ }
48
+ };
49
+ walk2(distDir, "");
50
+ return out;
51
+ }
52
+ function resolveParentThroughSymlinks(path) {
53
+ const trailing = [];
54
+ let cursor = dirname(path);
55
+ for (; ; ) {
56
+ if (existsSync(cursor)) {
57
+ return join(realpathSync(cursor), ...trailing.reverse(), basename(path));
58
+ }
59
+ const parent = dirname(cursor);
60
+ if (parent === cursor) return path;
61
+ trailing.push(basename(cursor));
62
+ cursor = parent;
63
+ }
64
+ }
65
+ function assertSafeDistDir(distDir, repoRoot) {
66
+ const rawRoot = resolve(repoRoot);
67
+ const root = existsSync(rawRoot) ? realpathSync(rawRoot) : rawRoot;
68
+ const dir = resolveParentThroughSymlinks(resolve(rawRoot, distDir));
69
+ const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`;
70
+ if (dir === root || !dir.startsWith(rootPrefix)) {
71
+ throw new Error(
72
+ `check-bundle-size: distDir ${JSON.stringify(distDir)} resolves to ${dir}, which is not inside ${root}. Refusing to remove it.`
73
+ );
74
+ }
75
+ return dir;
76
+ }
77
+ function cleanDist(distDir, repoRoot) {
78
+ rmSync(assertSafeDistDir(distDir, repoRoot), { recursive: true, force: true });
79
+ }
80
+ var TRAILING_SEGMENT = /-([A-Za-z0-9_-]{8}|[0-9a-f]{16,})(\.[^.]+)$/;
81
+ function looksLikeHash(segment) {
82
+ if (/^[0-9a-f]{16,}$/.test(segment)) return true;
83
+ if (/[0-9]/.test(segment)) return true;
84
+ return (segment.match(/[A-Z]/g) ?? []).length >= 2;
85
+ }
86
+ function logicalChunkName(name) {
87
+ const match = TRAILING_SEGMENT.exec(name);
88
+ if (!match?.[1] || !looksLikeHash(match[1])) return name;
89
+ return name.slice(0, match.index) + match[2];
90
+ }
91
+ var byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
92
+ function findDuplicateChunks(m) {
93
+ const groups = /* @__PURE__ */ new Map();
94
+ for (const f of m.files) {
95
+ const stripped = logicalChunkName(f.name);
96
+ if (stripped === f.name) continue;
97
+ const logical = f.dir ? posix.join(f.dir, stripped) : stripped;
98
+ const key = `${f.bucket}\0${logical}`;
99
+ const group = groups.get(key) ?? { bucket: f.bucket, logical, files: [] };
100
+ group.files.push({ name: f.name, raw: f.raw });
101
+ groups.set(key, group);
102
+ }
103
+ return [...groups.values()].filter((g) => g.files.length > 1).map((g) => ({ ...g, files: g.files.slice().sort((a, b) => byCodeUnit(a.name, b.name)) })).sort((a, b) => byCodeUnit(a.bucket, b.bucket) || byCodeUnit(a.logical, b.logical));
104
+ }
105
+ function loadBudgets(raw, source = "bundle-size-budgets") {
106
+ const parsed = JSON.parse(raw);
107
+ const out = {};
108
+ for (const [target, value] of Object.entries(parsed)) {
109
+ if (target.startsWith("_") || target.startsWith("$")) continue;
110
+ const v = value;
111
+ if (!v?.totals?.raw || !v.totals.gzip || !v.largest?.gzip) {
112
+ throw new Error(`${source}: ${target} missing totals.raw / totals.gzip / largest.gzip`);
113
+ }
114
+ out[target] = v;
115
+ }
116
+ return out;
117
+ }
118
+ function diff(target, m, budget) {
119
+ const failures = [];
120
+ for (const bucket of Object.keys(m.buckets)) {
121
+ const size = m.buckets[bucket];
122
+ if (!size) continue;
123
+ const checks = [
124
+ ["totals.raw", size.raw, budget.totals.raw[bucket]],
125
+ ["totals.gzip", size.gzip, budget.totals.gzip[bucket]],
126
+ ["largest.gzip", size.largest, budget.largest.gzip[bucket]]
127
+ ];
128
+ for (const [metric, actual, cap] of checks) {
129
+ if (cap !== void 0 && actual > cap) {
130
+ failures.push({ target, metric, bucket, actual, budget: cap });
131
+ }
132
+ }
133
+ }
134
+ return failures;
135
+ }
136
+ function seedBudget(m) {
137
+ const out = { totals: { raw: {}, gzip: {} }, largest: { gzip: {} } };
138
+ for (const [bucket, size] of Object.entries(m.buckets)) {
139
+ out.totals.raw[bucket] = size.raw + HEADROOM.raw;
140
+ out.totals.gzip[bucket] = size.gzip + HEADROOM.gzip;
141
+ out.largest.gzip[bucket] = size.largest + HEADROOM.gzip;
142
+ }
143
+ return out;
144
+ }
145
+ function fmtBytes(n) {
146
+ return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
147
+ }
148
+ function buildTargets(ctx) {
149
+ const filters = ctx.config.bundleSize.targets.flatMap((t) => ["--filter", t.filter]);
150
+ if (filters.length === 0) return 0;
151
+ const proc = spawnSync("pnpm", ["exec", "turbo", "run", "build", ...filters], {
152
+ cwd: ctx.repoRoot,
153
+ stdio: "inherit",
154
+ env: process.env
155
+ });
156
+ if (proc.error) {
157
+ console.error(`check-bundle-size: failed to build targets: ${proc.error.message}`);
158
+ return 1;
159
+ }
160
+ return proc.status ?? 1;
161
+ }
162
+ var DEFAULT_DEPS = { clean: cleanDist, build: buildTargets };
163
+ function runBundleSize(ctx, init = false, deps = DEFAULT_DEPS) {
164
+ const { budgetsPath, targets } = ctx.config.bundleSize;
165
+ if (targets.length === 0) {
166
+ console.log("Bundle-size guard: no targets configured \u2014 skipping.");
167
+ return 0;
168
+ }
169
+ for (const t of targets) deps.clean(t.distDir, ctx.repoRoot);
170
+ const buildExit = deps.build(ctx);
171
+ if (buildExit !== 0) return buildExit;
172
+ const path = resolve(ctx.repoRoot, budgetsPath);
173
+ const measured = [];
174
+ for (const t of targets) {
175
+ const m = measure(resolve(ctx.repoRoot, t.distDir), t.buckets);
176
+ if (m.files.length === 0) {
177
+ console.error(
178
+ `check-bundle-size: ${t.distDir} holds no files matching ${t.name}'s buckets (${Object.keys(t.buckets).join(", ")}) after building. Nothing was measured, so the ratchet would pass regardless of the real size.`
179
+ );
180
+ return 1;
181
+ }
182
+ measured.push({ target: t, m });
183
+ }
184
+ if (init) {
185
+ const budgets2 = {};
186
+ for (const { target: t, m } of measured) {
187
+ const duplicates = findDuplicateChunks(m);
188
+ if (duplicates.length > 0) {
189
+ console.error(
190
+ `check-bundle-size: refusing to seed "${t.name}" \u2014 ${t.distDir} holds ${duplicates.length} chunk(s) emitted more than once:`
191
+ );
192
+ for (const d of duplicates.slice(0, 5)) {
193
+ console.error(` ${d.logical}: ${d.files.map((f) => `${f.name} (${f.raw} B)`).join(", ")}`);
194
+ }
195
+ if (duplicates.length > 5) console.error(` \u2026 and ${duplicates.length - 5} more`);
196
+ console.error(
197
+ `
198
+ One build cannot emit the same chunk twice, so this directory holds output from two. A budget seeded from it would be inflated by the surplus and, because the ratchet only goes DOWN, nothing later would catch it.
199
+ Remove ${t.distDir} and re-run.`
200
+ );
201
+ return 1;
202
+ }
203
+ budgets2[t.name] = seedBudget(m);
204
+ }
205
+ const file = {
206
+ _comment: "Bundle-size budgets per target (bytes). Ratchet only goes DOWN \u2014 code-split/trim, then lower; never hand-raise. Re-baseline with `check:bundle-size --init`.",
207
+ ...budgets2
208
+ };
209
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
210
+ `);
211
+ console.log(`check-bundle-size: seeded budgets for ${targets.length} target(s) \u2192 ${path}`);
212
+ return 0;
213
+ }
214
+ const budgets = loadBudgets(readFileSync(path, "utf8"), budgetsPath);
215
+ const failures = [];
216
+ const scoreParts = [];
217
+ for (const { target: t, m } of measured) {
218
+ const budget = budgets[t.name];
219
+ if (!budget) {
220
+ console.error(`check-bundle-size: no budget for target "${t.name}" \u2014 run --init.`);
221
+ return 1;
222
+ }
223
+ failures.push(...diff(t.name, m, budget));
224
+ const gzipTotal = Object.values(m.buckets).reduce((s, b) => s + b.gzip, 0);
225
+ scoreParts.push(`${t.name} ${fmtBytes(gzipTotal)} gz`);
226
+ }
227
+ if (failures.length > 0) {
228
+ console.error("Bundle-size guard failed:");
229
+ for (const f of failures) {
230
+ console.error(
231
+ ` ${f.target} ${f.metric}.${f.bucket}: ${fmtBytes(f.actual)} > budget ${fmtBytes(f.budget)} (+${f.actual - f.budget} B)`
232
+ );
233
+ }
234
+ console.error(
235
+ "\nCode-split or trim deps to stay under budget.\n\nBefore re-baselining, confirm the growth is real: a budget seeded from a bad measurement raises the ceiling permanently, and the ratchet only goes DOWN, so nothing later will catch it. Check that the reported size matches what the build actually emitted. Only if the growth is intentional, re-baseline with `pnpm run check:bundle-size -- --init` and commit the budget diff."
236
+ );
237
+ return 1;
238
+ }
239
+ console.log(`SCORE: bundle-size \u2014 ${scoreParts.join(", ")}`);
240
+ console.log("Bundle-size guard ok.");
241
+ return 0;
242
+ }
243
+
244
+ // src/check-all.ts
245
+ import { spawnSync as spawnSync2 } from "child_process";
246
+
247
+ // src/lib/pkg.ts
248
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
249
+ import { resolve as resolve2 } from "path";
250
+ function loadScripts(repoRoot) {
251
+ const packageJsonPath = resolve2(repoRoot, "package.json");
252
+ if (!existsSync2(packageJsonPath)) return {};
253
+ const pkg = JSON.parse(readFileSync2(packageJsonPath, "utf8"));
254
+ return pkg.scripts ?? {};
255
+ }
256
+
257
+ // src/check-all.ts
258
+ function resolveGates(scripts, gates) {
259
+ return gates.filter((g) => !g.conditional || scripts[g.name] !== void 0).map((g) => g.name);
260
+ }
261
+ function gatesForRepo(ctx) {
262
+ return resolveGates(loadScripts(ctx.repoRoot), ctx.config.gates);
263
+ }
264
+ function formatGateLine(status, gate, seconds, width) {
265
+ const mark = status === "ok" ? "\u2713" : "\u2717";
266
+ return `${mark} ${gate.padEnd(width)} (${seconds.toFixed(1)}s)`;
267
+ }
268
+ var SIGNATURE_RE = /error TS\d+|: error |^error:|^✗ |^✖|^× |^FAIL\b|^Error: |\bAssertionError\b|\bbelow floor\b|\berror\b.*\bbudget\b|exceeds? .*budget|\[error\]|Code style issues/i;
269
+ var MAX_SIGNATURE_LINES = 50;
270
+ var TAIL_FALLBACK_LINES = 25;
271
+ function extractFailureSignatures(output) {
272
+ const lines = output.split("\n");
273
+ const matched = lines.filter((l) => SIGNATURE_RE.test(l.trim()));
274
+ if (matched.length > 0) return matched.slice(0, MAX_SIGNATURE_LINES);
275
+ return lines.filter((l) => l.trim() !== "").slice(-TAIL_FALLBACK_LINES);
276
+ }
277
+ var SCORE_PREFIX = "SCORE:";
278
+ function extractScores(output) {
279
+ return output.split("\n").map((l) => l.trim()).filter((l) => l.startsWith(SCORE_PREFIX)).map((l) => l.slice(SCORE_PREFIX.length).trim()).filter((l) => l.length > 0);
280
+ }
281
+ function formatScoresBlock(scores) {
282
+ if (scores.length === 0) return [];
283
+ const width = Math.max(...scores.map((s) => s.split("\u2014")[0]?.trim().length ?? 0));
284
+ const out = ["", "Scores:"];
285
+ for (const s of scores) {
286
+ const [label, ...rest] = s.split("\u2014");
287
+ out.push(
288
+ rest.length > 0 && label ? ` ${label.trim().padEnd(width)} ${rest.join("\u2014").trim()}` : ` ${s}`
289
+ );
290
+ }
291
+ return out;
292
+ }
293
+ function runGate(gate, ctx, verbose) {
294
+ const [cmd2, ...runnerArgs] = ctx.config.runner.split(/\s+/);
295
+ const start = performance.now();
296
+ const proc = spawnSync2(cmd2 ?? "pnpm", [...runnerArgs, gate], {
297
+ cwd: ctx.repoRoot,
298
+ stdio: verbose ? "inherit" : "pipe",
299
+ encoding: "utf8",
300
+ env: process.env
301
+ });
302
+ const seconds = (performance.now() - start) / 1e3;
303
+ const errNote = proc.error ? `
304
+ spawn error: ${proc.error.message}` : "";
305
+ const output = verbose ? errNote.trim() : `${proc.stdout ?? ""}
306
+ ${proc.stderr ?? ""}${errNote}`;
307
+ return { gate, ok: proc.status === 0 && !proc.error, seconds, output };
308
+ }
309
+ function runCheckAll(ctx, opts = {}) {
310
+ const verbose = opts.verbose ?? false;
311
+ const gates = gatesForRepo(ctx);
312
+ if (gates.length === 0) {
313
+ console.error("check:all \u2014 no gates resolved; check repo-gates.config.json");
314
+ return 1;
315
+ }
316
+ const width = Math.max(...gates.map((g) => g.length));
317
+ const results = [];
318
+ const overallStart = performance.now();
319
+ for (const gate of gates) {
320
+ if (verbose) console.log(`
321
+ \u2500\u2500 ${gate} \u2500\u2500`);
322
+ const result = runGate(gate, ctx, verbose);
323
+ results.push(result);
324
+ console.log(formatGateLine(result.ok ? "ok" : "fail", gate, result.seconds, width));
325
+ if (!result.ok && opts.bail) break;
326
+ }
327
+ const failures = results.filter((r) => !r.ok);
328
+ const totalSeconds = (performance.now() - overallStart) / 1e3;
329
+ if (failures.length === 0) {
330
+ console.log(`
331
+ ${results.length}/${gates.length} gates passed (${totalSeconds.toFixed(1)}s)`);
332
+ const scores = results.flatMap((r) => extractScores(r.output));
333
+ for (const line of formatScoresBlock(scores)) console.log(line);
334
+ return 0;
335
+ }
336
+ const runner = ctx.config.runner;
337
+ console.error(`
338
+ ${failures.length} gate(s) failed: ${failures.map((f) => f.gate).join(", ")}
339
+ `);
340
+ for (const f of failures) {
341
+ console.error(`\u2500\u2500 ${f.gate} \u2500\u2500`);
342
+ for (const line of extractFailureSignatures(f.output)) console.error(` ${line}`);
343
+ console.error(` \u21B3 re-run: ${runner} ${f.gate} (or CHECK_ALL_VERBOSE=1 ${runner} check:all)`);
344
+ }
345
+ return 1;
346
+ }
347
+
348
+ // src/ci-parity.ts
349
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
350
+ import { join as join2, relative, resolve as resolve3 } from "path";
351
+ import { parse } from "yaml";
352
+ function loadParityConfig(configPath) {
353
+ if (!existsSync3(configPath)) return { aliases: {}, ciOnly: /* @__PURE__ */ new Set() };
354
+ const raw = JSON.parse(readFileSync3(configPath, "utf8"));
355
+ return { aliases: raw.aliases ?? {}, ciOnly: new Set(raw.ciOnly ?? []) };
356
+ }
357
+ function escapeRegExp(s) {
358
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
359
+ }
360
+ function makeRunTargetRe(runner) {
361
+ return new RegExp(`${escapeRegExp(runner)}\\s+([A-Za-z][\\w:-]*)(?![\\w./:-])`, "g");
362
+ }
363
+ function extractRunTargets(command, runner) {
364
+ const out = [];
365
+ for (const match of command.matchAll(makeRunTargetRe(runner))) {
366
+ const name = match[1];
367
+ if (name) out.push(name);
368
+ }
369
+ return out;
370
+ }
371
+ function computeReachable(scripts, gates, entryGates, runner) {
372
+ const reachable = /* @__PURE__ */ new Set();
373
+ const stack = [...entryGates, ...gates];
374
+ while (stack.length > 0) {
375
+ const name = stack.pop();
376
+ if (!name || reachable.has(name)) continue;
377
+ reachable.add(name);
378
+ const body = scripts[name];
379
+ if (body === void 0) continue;
380
+ for (const dep of extractRunTargets(body, runner)) {
381
+ if (!reachable.has(dep)) stack.push(dep);
382
+ }
383
+ }
384
+ return reachable;
385
+ }
386
+ function extractCiInvocations(filePath, repoRoot, runner) {
387
+ const text = readFileSync3(filePath, "utf8");
388
+ const doc = parse(text);
389
+ const workflow = relative(repoRoot, filePath);
390
+ const out = [];
391
+ if (!doc || typeof doc !== "object" || !doc.jobs) return out;
392
+ for (const [jobName, job] of Object.entries(doc.jobs)) {
393
+ const steps = job?.steps;
394
+ if (!Array.isArray(steps)) continue;
395
+ steps.forEach((step, idx) => {
396
+ const run = step?.run;
397
+ if (typeof run !== "string") return;
398
+ for (const script of extractRunTargets(run, runner)) {
399
+ out.push({ workflow, job: jobName, step: idx, script });
400
+ }
401
+ });
402
+ }
403
+ return out;
404
+ }
405
+ function listCiWorkflows(dir, prefix) {
406
+ if (!existsSync3(dir)) return [];
407
+ return readdirSync2(dir).filter((f) => (f.endsWith(".yml") || f.endsWith(".yaml")) && f.startsWith(prefix)).map((f) => join2(dir, f)).sort();
408
+ }
409
+ function evaluateParity(invocations, reachable, config, rootGate) {
410
+ const failures = [];
411
+ for (const inv of invocations) {
412
+ const canonical = config.aliases[inv.script] ?? inv.script;
413
+ if (config.ciOnly.has(canonical)) continue;
414
+ if (reachable.has(canonical)) continue;
415
+ const reason = canonical === inv.script ? `not reachable from ${rootGate}` : `aliased to "${canonical}", which is not reachable from ${rootGate}`;
416
+ failures.push({ ...inv, canonical, reason });
417
+ }
418
+ return failures;
419
+ }
420
+ function checkParity(ctx) {
421
+ const { runner, ciParity } = ctx.config;
422
+ const reachable = computeReachable(
423
+ loadScripts(ctx.repoRoot),
424
+ gatesForRepo(ctx),
425
+ ciParity.entryGates,
426
+ runner
427
+ );
428
+ const workflowsDir = resolve3(ctx.repoRoot, ".github/workflows");
429
+ const invocations = [];
430
+ for (const wf of listCiWorkflows(workflowsDir, ciParity.workflowPrefix)) {
431
+ invocations.push(...extractCiInvocations(wf, ctx.repoRoot, runner));
432
+ }
433
+ const config = loadParityConfig(resolve3(ctx.repoRoot, ciParity.configPath));
434
+ const failures = evaluateParity(invocations, reachable, config, ciParity.rootGate);
435
+ return { invocations, reachable, failures };
436
+ }
437
+ function formatFailure(f) {
438
+ return ` ${f.workflow} (job=${f.job}, step=${f.step}): ${f.script} \u2014 ${f.reason}`;
439
+ }
440
+ function runCiParity(ctx) {
441
+ const { rootGate, configPath } = ctx.config.ciParity;
442
+ const { invocations, reachable, failures } = checkParity(ctx);
443
+ if (failures.length === 0) {
444
+ console.log(
445
+ `\u2713 CI parity: ${invocations.length} gate invocation(s) across CI workflows, all reachable from "${rootGate}" (${reachable.size} scripts in graph).`
446
+ );
447
+ return 0;
448
+ }
449
+ console.error(
450
+ `\u2717 CI parity drift: ${failures.length} CI step(s) invoke a script not reachable from "${rootGate}":
451
+ `
452
+ );
453
+ for (const f of failures) console.error(formatFailure(f));
454
+ console.error(
455
+ `
456
+ Fix one of:
457
+ - Wire the script into the gate manifest / a gate's script body.
458
+ - Change CI to invoke a script that is already reachable.
459
+ - If the step is intentionally CI-only, add it to "ciOnly" in ${configPath}.
460
+ - If two scripts run the same gate under different names, map the CI name
461
+ to its canonical equivalent in "aliases" in ${configPath}.`
462
+ );
463
+ return 1;
464
+ }
465
+
466
+ // src/circular-imports.ts
467
+ import { existsSync as existsSync5, lstatSync as lstatSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
468
+ import { dirname as dirname2, relative as relative2, resolve as resolve4 } from "path";
469
+
470
+ // src/lib/fs.ts
471
+ import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
472
+ import { join as join3 } from "path";
473
+ function* walk(dir, excludeDirSegments) {
474
+ if (!existsSync4(dir)) return;
475
+ for (const entry of readdirSync3(dir)) {
476
+ if (excludeDirSegments.includes(entry)) continue;
477
+ const full = join3(dir, entry);
478
+ const st = lstatSync2(full);
479
+ if (st.isSymbolicLink()) continue;
480
+ if (st.isDirectory()) {
481
+ yield* walk(full, excludeDirSegments);
482
+ } else if (st.isFile()) {
483
+ yield full;
484
+ }
485
+ }
486
+ }
487
+ function countLines(filePath) {
488
+ const buf = readFileSync4(filePath);
489
+ if (buf.length === 0) return 0;
490
+ let count = 0;
491
+ for (let i = 0; i < buf.length; i++) {
492
+ if (buf[i] === 10) count++;
493
+ }
494
+ if (buf[buf.length - 1] !== 10) count++;
495
+ return count;
496
+ }
497
+ function hasExtension(name, extensions) {
498
+ return extensions.some((ext) => name.endsWith(ext));
499
+ }
500
+
501
+ // src/circular-imports.ts
502
+ var IMPORT_SPEC_RE = /(?:from|require\()\s*["'](\.[^"'\n]+)["']|import\(\s*["'](\.[^"'\n]+)["']\s*\)|^\s*import\s+["'](\.[^"'\n]+)["']/gm;
503
+ function extractRelativeSpecifiers(source) {
504
+ const out = [];
505
+ for (const match of source.matchAll(IMPORT_SPEC_RE)) {
506
+ const spec = match[1] ?? match[2] ?? match[3];
507
+ if (spec) out.push(spec);
508
+ }
509
+ return out;
510
+ }
511
+ function resolveSpecifier(fromFile, spec, sourceExtensions) {
512
+ const base = resolve4(dirname2(fromFile), spec);
513
+ const candidates = [
514
+ base,
515
+ ...sourceExtensions.map((ext) => base + ext),
516
+ ...sourceExtensions.map((ext) => resolve4(base, `index${ext}`))
517
+ ];
518
+ return candidates.find(isFile);
519
+ }
520
+ function isFile(path) {
521
+ return existsSync5(path) && lstatSync3(path).isFile();
522
+ }
523
+ function findCycles(graph) {
524
+ let index = 0;
525
+ const indices = /* @__PURE__ */ new Map();
526
+ const lowlink = /* @__PURE__ */ new Map();
527
+ const onStack = /* @__PURE__ */ new Set();
528
+ const stack = [];
529
+ const sccs = [];
530
+ const strongConnect = (v) => {
531
+ indices.set(v, index);
532
+ lowlink.set(v, index);
533
+ index++;
534
+ stack.push(v);
535
+ onStack.add(v);
536
+ const frames = [
537
+ { node: v, neighbors: [...graph.get(v) ?? []], i: 0 }
538
+ ];
539
+ while (frames.length > 0) {
540
+ const frame = frames.at(-1);
541
+ if (!frame) break;
542
+ if (frame.i < frame.neighbors.length) {
543
+ const w = frame.neighbors[frame.i++];
544
+ if (w === void 0) continue;
545
+ if (!indices.has(w)) {
546
+ indices.set(w, index);
547
+ lowlink.set(w, index);
548
+ index++;
549
+ stack.push(w);
550
+ onStack.add(w);
551
+ frames.push({ node: w, neighbors: [...graph.get(w) ?? []], i: 0 });
552
+ } else if (onStack.has(w)) {
553
+ lowlink.set(frame.node, Math.min(lowlink.get(frame.node) ?? 0, indices.get(w) ?? 0));
554
+ }
555
+ } else {
556
+ frames.pop();
557
+ const parent = frames.at(-1);
558
+ if (parent) {
559
+ lowlink.set(
560
+ parent.node,
561
+ Math.min(lowlink.get(parent.node) ?? 0, lowlink.get(frame.node) ?? 0)
562
+ );
563
+ }
564
+ if (lowlink.get(frame.node) === indices.get(frame.node)) {
565
+ const scc = [];
566
+ let w;
567
+ do {
568
+ w = stack.pop();
569
+ if (w === void 0) break;
570
+ onStack.delete(w);
571
+ scc.push(w);
572
+ } while (w !== frame.node);
573
+ if (scc.length > 1) sccs.push(scc);
574
+ }
575
+ }
576
+ }
577
+ };
578
+ for (const node of graph.keys()) {
579
+ if (!indices.has(node)) strongConnect(node);
580
+ }
581
+ return sccs;
582
+ }
583
+ function buildGraph(ctx) {
584
+ const { scanRoots, excludeDirSegments, excludePathPrefixes, sourceExtensions } = ctx.config;
585
+ const graph = /* @__PURE__ */ new Map();
586
+ for (const root of scanRoots) {
587
+ for (const abs of walk(resolve4(ctx.repoRoot, root), excludeDirSegments)) {
588
+ if (!hasExtension(abs, sourceExtensions)) continue;
589
+ const rel = relative2(ctx.repoRoot, abs).replaceAll("\\", "/");
590
+ if (excludePathPrefixes.some((prefix) => rel.startsWith(prefix))) continue;
591
+ const source = readFileSync5(abs, "utf8");
592
+ const edges = graph.get(abs) ?? /* @__PURE__ */ new Set();
593
+ for (const spec of extractRelativeSpecifiers(source)) {
594
+ const target = resolveSpecifier(abs, spec, sourceExtensions);
595
+ if (target && target !== abs) edges.add(target);
596
+ }
597
+ graph.set(abs, edges);
598
+ }
599
+ }
600
+ return graph;
601
+ }
602
+ function toSignature(ctx, files) {
603
+ return files.map((f) => relative2(ctx.repoRoot, f).replaceAll("\\", "/")).sort((a, b) => a.localeCompare(b)).join(", ");
604
+ }
605
+ function parseAllowlist(allowlistPath) {
606
+ if (!existsSync5(allowlistPath)) return [];
607
+ const raw = JSON.parse(readFileSync5(allowlistPath, "utf8"));
608
+ const list = raw.allowlist;
609
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) {
610
+ throw new Error(`${allowlistPath}: "allowlist" must be an array of cycle-signature strings`);
611
+ }
612
+ return list;
613
+ }
614
+ function scan(ctx) {
615
+ const graph = buildGraph(ctx);
616
+ const cycles = findCycles(graph);
617
+ const signatures = cycles.map((files) => toSignature(ctx, files));
618
+ const allowlist = parseAllowlist(resolve4(ctx.repoRoot, ctx.config.circular.allowlistPath));
619
+ const allowSet = new Set(allowlist);
620
+ const matched = /* @__PURE__ */ new Set();
621
+ const untracked = [];
622
+ for (let i = 0; i < cycles.length; i++) {
623
+ const sig = signatures[i];
624
+ const files = cycles[i];
625
+ if (sig === void 0 || files === void 0) continue;
626
+ if (allowSet.has(sig)) matched.add(sig);
627
+ else untracked.push({ files: sig.split(", ") });
628
+ }
629
+ const staleAllowlistEntries = allowlist.filter((sig) => !matched.has(sig));
630
+ return { untracked, staleAllowlistEntries };
631
+ }
632
+ function seedAllowlist(ctx) {
633
+ const emptyCtx = {
634
+ ...ctx,
635
+ config: { ...ctx.config, circular: { allowlistPath: "\0missing" } }
636
+ };
637
+ return scan(emptyCtx).untracked.map((c) => c.files.join(", ")).sort((a, b) => a.localeCompare(b));
638
+ }
639
+ function writeSeed(ctx) {
640
+ const path = resolve4(ctx.repoRoot, ctx.config.circular.allowlistPath);
641
+ const allowlist = seedAllowlist(ctx);
642
+ const file = {
643
+ _comment: "Grandfathered circular-import groups (comma-separated, sorted repo-relative paths \u2014 one strongly-connected component per entry). Ratchet only goes down.",
644
+ allowlist
645
+ };
646
+ writeFileSync2(path, `${JSON.stringify(file, null, 2)}
647
+ `);
648
+ return { path, count: allowlist.length };
649
+ }
650
+ function runCircularImports(ctx, init = false) {
651
+ if (init) {
652
+ const { path, count } = writeSeed(ctx);
653
+ console.log(`circular-import guard: seeded ${count} grandfathered cycle(s) \u2192 ${path}`);
654
+ return 0;
655
+ }
656
+ const { untracked, staleAllowlistEntries } = scan(ctx);
657
+ if (staleAllowlistEntries.length > 0) {
658
+ console.error(`${ctx.config.circular.allowlistPath} has entries that no longer cycle:`);
659
+ for (const sig of staleAllowlistEntries) console.error(` - ${sig}`);
660
+ console.error("Remove these entries \u2014 the ratchet only goes down.\n");
661
+ }
662
+ if (untracked.length > 0) {
663
+ console.error("New circular imports found:");
664
+ for (const c of untracked) console.error(` ${c.files.join(" -> ")} -> ${c.files[0]}`);
665
+ console.error(
666
+ "\nBreak the cycle (extract shared code, invert one of the imports), or \u2014 with justification \u2014 allowlist it via `repo-gates check-circular --init`."
667
+ );
668
+ return 1;
669
+ }
670
+ if (staleAllowlistEntries.length > 0) return 1;
671
+ const grandfathered = parseAllowlist(resolve4(ctx.repoRoot, ctx.config.circular.allowlistPath)).length;
672
+ console.log(`SCORE: circular-imports \u2014 ${grandfathered} grandfathered, 0 new`);
673
+ console.log("Circular-import guard ok.");
674
+ return 0;
675
+ }
676
+
677
+ // src/config.ts
678
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
679
+ import { resolve as resolve5 } from "path";
680
+ var DEFAULT_CONFIG = {
681
+ runner: "pnpm run",
682
+ gates: [
683
+ { name: "lint", conditional: false },
684
+ { name: "check:design-system", conditional: true },
685
+ { name: "format:check", conditional: false },
686
+ { name: "typecheck", conditional: false },
687
+ { name: "check:scripts", conditional: true },
688
+ { name: "check:deps", conditional: true },
689
+ { name: "check:dups", conditional: true },
690
+ { name: "check:size", conditional: false },
691
+ { name: "check:debt", conditional: false },
692
+ { name: "check:circular", conditional: true },
693
+ { name: "check:secrets", conditional: true },
694
+ { name: "check:agents", conditional: true },
695
+ { name: "check:shadscan", conditional: true },
696
+ { name: "check:docs-coverage", conditional: true },
697
+ { name: "check:bundle-size", conditional: true },
698
+ { name: "test", conditional: false },
699
+ { name: "check:coverage", conditional: true },
700
+ { name: "check:ci-parity", conditional: false }
701
+ ],
702
+ scanRoots: ["apps", "packages", "scripts"],
703
+ excludeDirSegments: [
704
+ "node_modules",
705
+ "dist",
706
+ "dist-e2e",
707
+ "out",
708
+ ".turbo",
709
+ ".wrangler",
710
+ "__golden__",
711
+ "_generated",
712
+ "coverage"
713
+ ],
714
+ excludePathPrefixes: [],
715
+ sourceExtensions: [".ts", ".tsx"],
716
+ fileSize: { threshold: 600, budgetsPath: "gates/file-size-budgets.json" },
717
+ debt: {
718
+ allowlistPath: "gates/debt-marker-allowlist.json",
719
+ markerTokens: ["TODO", "FIXME", "HACK", "XXX"],
720
+ trackerPatterns: ["\\b[A-Z]{2,}-\\d+\\b", "#\\d+\\b", "https?:\\/\\/\\S+"]
721
+ },
722
+ circular: { allowlistPath: "gates/circular-imports-allowlist.json" },
723
+ secrets: {
724
+ allowlistPath: "gates/secrets-allowlist.json",
725
+ patterns: [
726
+ "\\bAKIA[0-9A-Z]{16}\\b",
727
+ // AWS access key
728
+ "\\bASIA[0-9A-Z]{16}\\b",
729
+ // AWS temporary access key
730
+ "\\bgh[pousr]_[A-Za-z0-9]{36,}\\b",
731
+ // GitHub personal/OAuth/app/refresh token
732
+ "\\bxox[baprs]-[A-Za-z0-9-]{10,}\\b",
733
+ // Slack token
734
+ "\\bsk_(?:live|test)_[A-Za-z0-9]{16,}\\b",
735
+ // Stripe secret key
736
+ "\\bnpm_[A-Za-z0-9]{36}\\b",
737
+ // npm access token
738
+ "\\bAIza[0-9A-Za-z_-]{35}\\b",
739
+ // Google API key
740
+ "-----BEGIN(?: RSA| EC| OPENSSH| DSA)? PRIVATE KEY-----",
741
+ // PEM private key
742
+ "://[^/\\s:@]+:[^/\\s:@]+@"
743
+ // credentials embedded in a URL
744
+ ],
745
+ binaryExtensions: [
746
+ ".png",
747
+ ".jpg",
748
+ ".jpeg",
749
+ ".gif",
750
+ ".ico",
751
+ ".webp",
752
+ ".pdf",
753
+ ".zip",
754
+ ".gz",
755
+ ".woff",
756
+ ".woff2",
757
+ ".ttf",
758
+ ".eot",
759
+ ".mp4",
760
+ ".mp3",
761
+ ".wasm"
762
+ ]
763
+ },
764
+ coverage: {
765
+ budgetsPath: "gates/coverage-budgets.json",
766
+ summaryGlobs: [
767
+ "apps/*/coverage/coverage-summary.json",
768
+ "packages/*/coverage/coverage-summary.json"
769
+ ]
770
+ },
771
+ ciParity: {
772
+ configPath: "gates/ci-parity-config.json",
773
+ rootGate: "check:all",
774
+ entryGates: ["check:all", "verify"],
775
+ workflowPrefix: "ci"
776
+ },
777
+ bundleSize: { budgetsPath: "gates/bundle-size-budgets.json", targets: [] },
778
+ report: { junitGlobs: [], topN: 20 },
779
+ agents: {
780
+ targets: [],
781
+ knownMissingPaths: [],
782
+ runnerCommand: "pnpm",
783
+ ignoredSubcommands: [
784
+ "run",
785
+ "install",
786
+ "i",
787
+ "add",
788
+ "remove",
789
+ "rm",
790
+ "up",
791
+ "update",
792
+ "exec",
793
+ "dlx",
794
+ "why",
795
+ "store",
796
+ "prune",
797
+ "audit",
798
+ "outdated",
799
+ "list",
800
+ "ls",
801
+ "link",
802
+ "unlink",
803
+ "publish",
804
+ "pack",
805
+ "rebuild",
806
+ "approve-builds",
807
+ "config",
808
+ "dedupe",
809
+ "fetch",
810
+ "import",
811
+ "patch",
812
+ "setup",
813
+ "create"
814
+ ]
815
+ },
816
+ docsCoverage: { docsGlobs: [], surfaces: [], exclude: [] }
817
+ };
818
+ var CONFIG_FILENAMES = ["repo-gates.config.json"];
819
+ function isPlainObject(value) {
820
+ return value !== null && typeof value === "object" && !Array.isArray(value);
821
+ }
822
+ function mergeConfig(base, overlay) {
823
+ const out = { ...base };
824
+ for (const [key, value] of Object.entries(overlay)) {
825
+ if (value === void 0) continue;
826
+ const baseValue = base[key];
827
+ out[key] = isPlainObject(baseValue) && isPlainObject(value) ? { ...baseValue, ...value } : value;
828
+ }
829
+ return out;
830
+ }
831
+ function findConfigFile(repoRoot) {
832
+ for (const name of CONFIG_FILENAMES) {
833
+ const candidate = resolve5(repoRoot, name);
834
+ if (existsSync6(candidate)) return candidate;
835
+ }
836
+ return void 0;
837
+ }
838
+ function loadConfig(repoRoot = process.cwd()) {
839
+ const file = findConfigFile(repoRoot);
840
+ if (!file) return DEFAULT_CONFIG;
841
+ const overlay = JSON.parse(readFileSync6(file, "utf8"));
842
+ return mergeConfig(DEFAULT_CONFIG, overlay);
843
+ }
844
+ function loadContext(repoRoot = process.cwd()) {
845
+ return { repoRoot, config: loadConfig(repoRoot) };
846
+ }
847
+
848
+ // src/coverage.ts
849
+ import { spawnSync as spawnSync3 } from "child_process";
850
+ import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "fs";
851
+ import { join as join4, relative as relative3, resolve as resolve6 } from "path";
852
+ var DEFAULT_MIN = { functions: 80, lines: 80 };
853
+ function validFloor(value, source, where) {
854
+ const v = value;
855
+ const out = { functions: 0, lines: 0 };
856
+ for (const metric of ["functions", "lines"]) {
857
+ const n = v?.[metric];
858
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 0 || n > 100) {
859
+ throw new Error(`${source}: ${where}.${metric} must be a percentage in [0, 100]`);
860
+ }
861
+ out[metric] = n;
862
+ }
863
+ return out;
864
+ }
865
+ function loadBudgets2(raw, source = "coverage-budgets") {
866
+ const parsed = JSON.parse(raw);
867
+ const def = parsed.default === void 0 ? DEFAULT_MIN : validFloor(parsed.default, source, "default");
868
+ const pkgsRaw = parsed.packages;
869
+ if (pkgsRaw !== void 0 && (pkgsRaw === null || typeof pkgsRaw !== "object" || Array.isArray(pkgsRaw))) {
870
+ throw new Error(`${source}: "packages" must be an object`);
871
+ }
872
+ const packages = {};
873
+ for (const [pkg, floor] of Object.entries(pkgsRaw ?? {})) {
874
+ packages[pkg] = validFloor(floor, source, `packages["${pkg}"]`);
875
+ }
876
+ return { default: def, packages };
877
+ }
878
+ function expandGlob(repoRoot, glob) {
879
+ const segments = glob.split("/");
880
+ let dirs = [repoRoot];
881
+ for (let i = 0; i < segments.length; i++) {
882
+ const seg = segments[i] ?? "";
883
+ const isLast = i === segments.length - 1;
884
+ const next = [];
885
+ for (const dir of dirs) {
886
+ if (seg === "*") {
887
+ if (!existsSync7(dir)) continue;
888
+ for (const entry of readdirSync4(dir)) {
889
+ const full = join4(dir, entry);
890
+ if (statSync(full).isDirectory()) next.push(full);
891
+ }
892
+ } else {
893
+ const full = join4(dir, seg);
894
+ if (isLast ? existsSync7(full) : existsSync7(full) && statSync(full).isDirectory()) {
895
+ next.push(full);
896
+ }
897
+ }
898
+ }
899
+ dirs = next;
900
+ }
901
+ return dirs;
902
+ }
903
+ function readSummaryCounts(path) {
904
+ const doc = JSON.parse(readFileSync7(path, "utf8"));
905
+ const total = doc.total;
906
+ if (!total) return void 0;
907
+ const pick = (m) => {
908
+ const covered = m?.covered;
909
+ const total_ = m?.total;
910
+ if (typeof covered !== "number" || typeof total_ !== "number") return void 0;
911
+ return { covered, total: total_ };
912
+ };
913
+ const functions = pick(total.functions);
914
+ const lines = pick(total.lines);
915
+ if (!functions || !lines) return void 0;
916
+ return { functions, lines };
917
+ }
918
+ function pct(counts) {
919
+ return counts.total === 0 ? 100 : counts.covered / counts.total * 100;
920
+ }
921
+ function pkgKey(repoRoot, summaryPath) {
922
+ return relative3(repoRoot, summaryPath).replaceAll("\\", "/").replace(/\/coverage\/coverage-summary\.json$/, "");
923
+ }
924
+ function collectPerPackage(ctx) {
925
+ const out = [];
926
+ for (const glob of ctx.config.coverage.summaryGlobs) {
927
+ for (const path of expandGlob(ctx.repoRoot, glob)) {
928
+ const counts = readSummaryCounts(path);
929
+ if (!counts) continue;
930
+ out.push({
931
+ pkg: pkgKey(ctx.repoRoot, path),
932
+ totals: { functions: pct(counts.functions), lines: pct(counts.lines) }
933
+ });
934
+ }
935
+ }
936
+ return out.sort((a, b) => a.pkg.localeCompare(b.pkg));
937
+ }
938
+ function checkPerPackage(perPkg, budgets) {
939
+ const failures = [];
940
+ const newPkgs = [];
941
+ const seen = /* @__PURE__ */ new Set();
942
+ for (const { pkg, totals } of perPkg) {
943
+ seen.add(pkg);
944
+ const explicit = budgets.packages[pkg];
945
+ const floor = explicit ?? budgets.default;
946
+ const isNew = explicit === void 0;
947
+ if (isNew) newPkgs.push(pkg);
948
+ for (const metric of ["functions", "lines"]) {
949
+ if (totals[metric] < floor[metric]) {
950
+ failures.push({ pkg, metric, actual: totals[metric], floor: floor[metric], isNew });
951
+ }
952
+ }
953
+ }
954
+ const stale = Object.keys(budgets.packages).filter((p) => !seen.has(p));
955
+ return { failures, newPkgs, stale };
956
+ }
957
+ function seedFloor(pct_) {
958
+ return Math.max(0, Math.floor((pct_ - 0.5) * 100) / 100);
959
+ }
960
+ function seedBudgets(perPkg, keepDefault) {
961
+ const packages = {};
962
+ for (const { pkg, totals } of perPkg.slice().sort((a, b) => a.pkg.localeCompare(b.pkg))) {
963
+ packages[pkg] = { functions: seedFloor(totals.functions), lines: seedFloor(totals.lines) };
964
+ }
965
+ return { default: keepDefault, packages };
966
+ }
967
+ function runTestCoverage(ctx) {
968
+ const [cmd2, ...args] = ctx.config.runner.split(/\s+/);
969
+ const proc = spawnSync3(cmd2 ?? "pnpm", [...args, "test:coverage"], {
970
+ cwd: ctx.repoRoot,
971
+ stdio: "inherit",
972
+ env: process.env
973
+ });
974
+ if (proc.error) {
975
+ console.error(
976
+ `check-coverage: could not run '${ctx.config.runner} test:coverage': ${proc.error.message}`
977
+ );
978
+ return 1;
979
+ }
980
+ return proc.status ?? 1;
981
+ }
982
+ function lowest(perPkg) {
983
+ let min;
984
+ for (const { pkg, totals } of perPkg) {
985
+ if (!min || totals.lines < min.lines) min = { pkg, lines: totals.lines };
986
+ }
987
+ return min;
988
+ }
989
+ function coverageScore(perPkg) {
990
+ const min = lowest(perPkg);
991
+ if (!min) return void 0;
992
+ return `coverage \u2014 lowest ${min.pkg} ${min.lines.toFixed(1)}% lines (${perPkg.length} pkgs \u2265 floor)`;
993
+ }
994
+ function runCoverage(ctx, opts = {}) {
995
+ const budgetsPath = resolve6(ctx.repoRoot, ctx.config.coverage.budgetsPath);
996
+ const testExit = opts.skipRun ? 0 : runTestCoverage(ctx);
997
+ const perPkg = collectPerPackage(ctx);
998
+ if (perPkg.length === 0) {
999
+ console.error(
1000
+ `check-coverage: no coverage-summary.json files matched ${ctx.config.coverage.summaryGlobs.join(", ")} \u2014 did test:coverage run with the json-summary reporter?`
1001
+ );
1002
+ return testExit === 0 ? 1 : testExit;
1003
+ }
1004
+ if (opts.init) {
1005
+ if (!opts.skipRun && testExit !== 0) {
1006
+ console.error(
1007
+ "check-coverage: --init aborted \u2014 test:coverage failed; refusing to seed floors from a failed/partial run. Fix the tests, then re-seed."
1008
+ );
1009
+ return testExit;
1010
+ }
1011
+ const keepDefault = existsSync7(budgetsPath) ? loadBudgets2(readFileSync7(budgetsPath, "utf8"), ctx.config.coverage.budgetsPath).default : DEFAULT_MIN;
1012
+ const budgets2 = seedBudgets(perPkg, keepDefault);
1013
+ const file = {
1014
+ _comment: "Per-package coverage floors. Each package is held to its own floor (packages[dir]) or `default` if unlisted; floors only ratchet UP. Seeded 0.5pt below each baseline.",
1015
+ default: budgets2.default,
1016
+ packages: budgets2.packages
1017
+ };
1018
+ writeFileSync3(budgetsPath, `${JSON.stringify(file, null, 2)}
1019
+ `);
1020
+ console.log(
1021
+ `check-coverage: seeded per-package floors for ${perPkg.length} package(s) (default ${budgets2.default.functions}/${budgets2.default.lines}) \u2192 ${budgetsPath}`
1022
+ );
1023
+ return 0;
1024
+ }
1025
+ const budgets = loadBudgets2(readFileSync7(budgetsPath, "utf8"), ctx.config.coverage.budgetsPath);
1026
+ const { failures, newPkgs, stale } = checkPerPackage(perPkg, budgets);
1027
+ if (stale.length > 0) {
1028
+ console.error(`${ctx.config.coverage.budgetsPath} lists packages with no coverage summary:`);
1029
+ for (const p of stale) console.error(` - ${p}`);
1030
+ console.error("Remove these entries (or restore the package's coverage).\n");
1031
+ }
1032
+ if (failures.length > 0) {
1033
+ console.error("Coverage below floor:");
1034
+ for (const f of failures) {
1035
+ const tag = f.isNew ? " [new package \u2014 no explicit floor, using default]" : "";
1036
+ console.error(
1037
+ ` ${f.pkg}: ${f.metric} ${f.actual.toFixed(2)}% < floor ${f.floor.toFixed(2)}%${tag}`
1038
+ );
1039
+ }
1040
+ console.error(
1041
+ `
1042
+ Add tests to lift it, or \u2014 if intentional \u2014 lower that package's floor in ${ctx.config.coverage.budgetsPath}. A new package needs tests to clear the default (${budgets.default.functions}%/${budgets.default.lines}%) or an explicit floor via --init.`
1043
+ );
1044
+ return testExit === 0 ? 1 : testExit;
1045
+ }
1046
+ if (stale.length > 0) return 1;
1047
+ const min = lowest(perPkg);
1048
+ const newNote = newPkgs.length > 0 ? `; ${newPkgs.length} new pkg(s) met default` : "";
1049
+ console.error(
1050
+ `Coverage OK \u2014 ${perPkg.length} package(s) meet their floor` + (min ? `; lowest lines: ${min.pkg} ${min.lines.toFixed(2)}%` : "") + newNote
1051
+ );
1052
+ const score = coverageScore(perPkg);
1053
+ if (score) console.log(`SCORE: ${score}`);
1054
+ return testExit;
1055
+ }
1056
+
1057
+ // src/debt-markers.ts
1058
+ import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
1059
+ import { relative as relative4, resolve as resolve7 } from "path";
1060
+ function parseAllowlist2(allowlistPath) {
1061
+ if (!existsSync8(allowlistPath)) return { entries: [], raw: [] };
1062
+ const raw = JSON.parse(readFileSync8(allowlistPath, "utf8"));
1063
+ const list = raw.allowlist;
1064
+ if (!Array.isArray(list)) {
1065
+ throw new Error(`${allowlistPath}: "allowlist" must be an array of "path:line" strings`);
1066
+ }
1067
+ const entries = [];
1068
+ const rawStrings = [];
1069
+ for (const item of list) {
1070
+ if (typeof item !== "string") {
1071
+ throw new Error(`${allowlistPath}: allowlist entries must be "path:line" strings`);
1072
+ }
1073
+ const idx = item.lastIndexOf(":");
1074
+ const path = idx < 0 ? "" : item.slice(0, idx);
1075
+ const line = idx < 0 ? Number.NaN : Number.parseInt(item.slice(idx + 1), 10);
1076
+ if (!path || !Number.isInteger(line) || line <= 0) {
1077
+ throw new Error(`${allowlistPath}: "${item}" is not a valid "path:line" entry`);
1078
+ }
1079
+ entries.push({ path, line });
1080
+ rawStrings.push(item);
1081
+ }
1082
+ return { entries, raw: rawStrings };
1083
+ }
1084
+ function buildMarkerRe(tokens) {
1085
+ return new RegExp(`\\b(${tokens.join("|")})\\b`);
1086
+ }
1087
+ function buildTrackerRes(patterns) {
1088
+ return patterns.map((p) => new RegExp(p, "i"));
1089
+ }
1090
+ function shouldExclude(relPath, prefixes, exact) {
1091
+ if (exact.has(relPath)) return true;
1092
+ return prefixes.some((prefix) => relPath.startsWith(prefix));
1093
+ }
1094
+ function scan2(ctx) {
1095
+ const { scanRoots, excludeDirSegments, excludePathPrefixes, sourceExtensions, debt } = ctx.config;
1096
+ const markerRe = buildMarkerRe(debt.markerTokens);
1097
+ const trackerRes = buildTrackerRes(debt.trackerPatterns);
1098
+ const { entries: allowlist, raw } = parseAllowlist2(resolve7(ctx.repoRoot, debt.allowlistPath));
1099
+ const allowSet = new Set(raw);
1100
+ const matchedAllow = /* @__PURE__ */ new Set();
1101
+ const excludeExact = /* @__PURE__ */ new Set([
1102
+ "packages/repo-gates/src/config.ts",
1103
+ "packages/repo-gates/src/debt-markers.ts",
1104
+ "packages/repo-gates/src/debt-markers.test.ts"
1105
+ ]);
1106
+ const untracked = [];
1107
+ for (const root of scanRoots) {
1108
+ for (const abs of walk(resolve7(ctx.repoRoot, root), excludeDirSegments)) {
1109
+ const rel = relative4(ctx.repoRoot, abs).replaceAll("\\", "/");
1110
+ if (!hasExtension(rel, sourceExtensions)) continue;
1111
+ if (shouldExclude(rel, excludePathPrefixes, excludeExact)) continue;
1112
+ const lines = readFileSync8(abs, "utf8").split("\n");
1113
+ for (let i = 0; i < lines.length; i++) {
1114
+ const line = lines[i] ?? "";
1115
+ const match = line.match(markerRe);
1116
+ if (!match) continue;
1117
+ if (trackerRes.some((re) => re.test(line))) continue;
1118
+ const key = `${rel}:${i + 1}`;
1119
+ const marker = {
1120
+ path: rel,
1121
+ line: i + 1,
1122
+ marker: match[1] ?? match[0],
1123
+ text: line.trim()
1124
+ };
1125
+ if (allowSet.has(key)) matchedAllow.add(key);
1126
+ else untracked.push(marker);
1127
+ }
1128
+ }
1129
+ }
1130
+ const staleAllowlistEntries = allowlist.map((e) => `${e.path}:${e.line}`).filter((key) => !matchedAllow.has(key));
1131
+ return { untracked, staleAllowlistEntries };
1132
+ }
1133
+ function seedAllowlist2(ctx) {
1134
+ const emptyCtx = {
1135
+ ...ctx,
1136
+ config: { ...ctx.config, debt: { ...ctx.config.debt, allowlistPath: "\0missing" } }
1137
+ };
1138
+ return scan2(emptyCtx).untracked.map((m) => `${m.path}:${m.line}`).sort((a, b) => a.localeCompare(b));
1139
+ }
1140
+ function writeSeed2(ctx) {
1141
+ const path = resolve7(ctx.repoRoot, ctx.config.debt.allowlistPath);
1142
+ const allowlist = seedAllowlist2(ctx);
1143
+ const file = {
1144
+ _comment: "Grandfathered untracked debt markers (path:line). Each must match an existing TODO/FIXME/HACK/XXX with no tracker ref on the same line. Ratchet only goes down.",
1145
+ allowlist
1146
+ };
1147
+ writeFileSync4(path, `${JSON.stringify(file, null, 2)}
1148
+ `);
1149
+ return { path, count: allowlist.length };
1150
+ }
1151
+ function runDebtMarkers(ctx, init = false) {
1152
+ if (init) {
1153
+ const { path, count } = writeSeed2(ctx);
1154
+ console.log(`debt-marker guard: seeded ${count} grandfathered marker(s) \u2192 ${path}`);
1155
+ return 0;
1156
+ }
1157
+ const { untracked, staleAllowlistEntries } = scan2(ctx);
1158
+ if (staleAllowlistEntries.length > 0) {
1159
+ console.error(`${ctx.config.debt.allowlistPath} has entries that no longer match a marker:`);
1160
+ for (const k of staleAllowlistEntries) console.error(` - ${k}`);
1161
+ console.error("Remove these entries \u2014 the ratchet only goes down.\n");
1162
+ }
1163
+ if (untracked.length > 0) {
1164
+ console.error("Untracked debt markers found:");
1165
+ for (const m of untracked) console.error(` ${m.path}:${m.line} ${m.marker}: ${m.text}`);
1166
+ console.error(
1167
+ `
1168
+ Pair each marker with a tracker reference on the same line (ABC-123 / #123 / URL), remove it, or \u2014 with justification \u2014 allowlist it.`
1169
+ );
1170
+ return 1;
1171
+ }
1172
+ if (staleAllowlistEntries.length > 0) return 1;
1173
+ const grandfathered = parseAllowlist2(resolve7(ctx.repoRoot, ctx.config.debt.allowlistPath)).raw.length;
1174
+ console.log(`SCORE: debt-markers \u2014 ${grandfathered} grandfathered, 0 untracked`);
1175
+ console.log("Debt-marker guard ok.");
1176
+ return 0;
1177
+ }
1178
+
1179
+ // src/docs-coverage.ts
1180
+ import { readFileSync as readFileSync9 } from "fs";
1181
+ function globToRegExp(glob) {
1182
+ let re = "";
1183
+ for (let i = 0; i < glob.length; i++) {
1184
+ const c = glob[i];
1185
+ if (c === "*") {
1186
+ if (glob[i + 1] === "*") {
1187
+ re += ".*";
1188
+ i++;
1189
+ if (glob[i + 1] === "/") i++;
1190
+ } else {
1191
+ re += "[^/]*";
1192
+ }
1193
+ } else if (c && "\\^$.|?+()[]{}/".includes(c)) {
1194
+ re += `\\${c}`;
1195
+ } else {
1196
+ re += c;
1197
+ }
1198
+ }
1199
+ return new RegExp(`^${re}$`);
1200
+ }
1201
+ function matchesAny(path, globs) {
1202
+ return (globs ?? []).some((g) => globToRegExp(g).test(path));
1203
+ }
1204
+ var ADDED = /* @__PURE__ */ new Set(["added", "copied"]);
1205
+ var PRESENT = /* @__PURE__ */ new Set(["added", "copied", "modified", "renamed", "changed"]);
1206
+ function fileTriggersSurface(file, surface) {
1207
+ if (!globToRegExp(surface.glob).test(file.filename)) return false;
1208
+ const set = surface.on === "added" ? ADDED : PRESENT;
1209
+ return set.has(file.status);
1210
+ }
1211
+ function triggeredSurfaces(files, config) {
1212
+ const out = [];
1213
+ for (const file of files) {
1214
+ if (matchesAny(file.filename, config.exclude)) continue;
1215
+ for (const surface of config.surfaces) {
1216
+ if (fileTriggersSurface(file, surface)) {
1217
+ out.push({ file: file.filename, label: surface.label });
1218
+ break;
1219
+ }
1220
+ }
1221
+ }
1222
+ return out;
1223
+ }
1224
+ function hasDocsChange(files, config) {
1225
+ return files.some(
1226
+ (f) => PRESENT.has(f.status) && !matchesAny(f.filename, config.exclude) && matchesAny(f.filename, config.docsGlobs)
1227
+ );
1228
+ }
1229
+ function stripFencedCode(markdown) {
1230
+ return (markdown ?? "").replace(/```[\s\S]*?```/g, "").replace(/~~~[\s\S]*?~~~/g, "");
1231
+ }
1232
+ function escapeHatch(body) {
1233
+ const line = /^[ \t>*-]*docs:\s*n\/?a\b[ \t]*[-:—]?[ \t]*(.*)$/im;
1234
+ const match = line.exec(stripFencedCode(body));
1235
+ if (!match) return { present: false };
1236
+ return { present: true, reason: (match[1] ?? "").trim() };
1237
+ }
1238
+ function evaluate(files, body, config) {
1239
+ const triggered = triggeredSurfaces(files, config);
1240
+ if (triggered.length === 0) return { status: "skip" };
1241
+ if (hasDocsChange(files, config)) return { status: "pass", triggered };
1242
+ const hatch = escapeHatch(body);
1243
+ if (hatch.present) return { status: "waived", triggered, reason: hatch.reason };
1244
+ return { status: "fail", triggered };
1245
+ }
1246
+ function escapeWorkflowCommand(message) {
1247
+ return message.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
1248
+ }
1249
+ function notice(message) {
1250
+ console.log(`::notice title=Docs coverage::${escapeWorkflowCommand(message)}`);
1251
+ }
1252
+ async function fetchChangedFiles(opts) {
1253
+ const files = [];
1254
+ for (let page = 1; page <= 30; page++) {
1255
+ const res = await fetch(
1256
+ `https://api.github.com/repos/${opts.repo}/pulls/${opts.number}/files?per_page=100&page=${page}`,
1257
+ {
1258
+ headers: {
1259
+ Authorization: `Bearer ${opts.token}`,
1260
+ Accept: "application/vnd.github+json",
1261
+ "X-GitHub-Api-Version": "2022-11-28",
1262
+ "User-Agent": "repo-gates-docs-coverage"
1263
+ },
1264
+ signal: AbortSignal.timeout(15e3)
1265
+ }
1266
+ );
1267
+ if (!res.ok) {
1268
+ throw new Error(`GitHub API ${res.status} listing PR files: ${await res.text()}`);
1269
+ }
1270
+ const batch = await res.json();
1271
+ for (const f of batch) files.push({ filename: f.filename, status: f.status });
1272
+ if (batch.length < 100) break;
1273
+ }
1274
+ return files;
1275
+ }
1276
+ async function runDocsCoverage(ctx) {
1277
+ const config = ctx.config.docsCoverage;
1278
+ if (config.surfaces.length === 0) {
1279
+ console.log("docs-coverage: no surfaces configured \u2014 no-op.");
1280
+ return 0;
1281
+ }
1282
+ const repo = process.env.GITHUB_REPOSITORY;
1283
+ const number = process.env.PR_NUMBER;
1284
+ const token = process.env.GITHUB_TOKEN;
1285
+ const body = process.env.PR_BODY ?? "";
1286
+ if (!repo || !number || !token) {
1287
+ console.log("docs-coverage: not running in a PR context (no GITHUB_REPOSITORY/PR_NUMBER/GITHUB_TOKEN) \u2014 skipping.");
1288
+ return 0;
1289
+ }
1290
+ if (!/^\d+$/.test(number)) {
1291
+ console.error(`docs-coverage: PR_NUMBER is not numeric: ${JSON.stringify(number)}`);
1292
+ return 1;
1293
+ }
1294
+ const files = await fetchChangedFiles({ repo, number, token });
1295
+ const result = evaluate(files, body, config);
1296
+ if (result.status === "skip") {
1297
+ notice("No user-facing surface changed in this PR \u2014 nothing to gate.");
1298
+ return 0;
1299
+ }
1300
+ const list = result.triggered.map((t) => ` - ${t.file} (${t.label})`).join("\n");
1301
+ if (result.status === "pass") {
1302
+ console.log("SCORE: docs-coverage \u2014 surface changes accompanied by docs");
1303
+ console.log("docs-coverage: surface changes are accompanied by docs.");
1304
+ return 0;
1305
+ }
1306
+ if (result.status === "waived") {
1307
+ notice(
1308
+ `Docs opt-out accepted: "docs: n/a${result.reason ? ` - ${result.reason}` : ""}". Surface(s):
1309
+ ${list}`
1310
+ );
1311
+ return 0;
1312
+ }
1313
+ console.error("docs-coverage: this PR changes a user-facing surface but adds no docs:");
1314
+ console.error(list);
1315
+ console.error(
1316
+ "\n Add or update docs for the change, OR \u2014 if it genuinely needs none \u2014 add a line"
1317
+ );
1318
+ console.error(" `docs: n/a - <reason>` to the PR body to opt out on the record.");
1319
+ return 1;
1320
+ }
1321
+
1322
+ // src/file-sizes.ts
1323
+ import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync5 } from "fs";
1324
+ import { relative as relative5, resolve as resolve8 } from "path";
1325
+ function loadBudgets3(budgetsPath) {
1326
+ if (!existsSync9(budgetsPath)) return {};
1327
+ const raw = JSON.parse(readFileSync10(budgetsPath, "utf8"));
1328
+ const budgets = raw.budgets;
1329
+ if (budgets === null || typeof budgets !== "object" || Array.isArray(budgets)) {
1330
+ throw new Error(`${budgetsPath}: "budgets" must be an object`);
1331
+ }
1332
+ const normalized = {};
1333
+ for (const [path, value] of Object.entries(budgets)) {
1334
+ if (typeof value !== "number" || value <= 0) {
1335
+ throw new Error(`${budgetsPath}: budgets["${path}"] must be a positive number`);
1336
+ }
1337
+ normalized[path] = value;
1338
+ }
1339
+ return normalized;
1340
+ }
1341
+ function shouldExclude2(relPath, prefixes) {
1342
+ return prefixes.some((prefix) => relPath.startsWith(prefix));
1343
+ }
1344
+ function collectFiles(ctx) {
1345
+ const { scanRoots, excludeDirSegments, excludePathPrefixes, sourceExtensions } = ctx.config;
1346
+ const out = [];
1347
+ for (const root of scanRoots) {
1348
+ for (const abs of walk(resolve8(ctx.repoRoot, root), excludeDirSegments)) {
1349
+ const rel = relative5(ctx.repoRoot, abs).replaceAll("\\", "/");
1350
+ if (!hasExtension(rel, sourceExtensions)) continue;
1351
+ if (shouldExclude2(rel, excludePathPrefixes)) continue;
1352
+ out.push({ rel, lines: countLines(abs) });
1353
+ }
1354
+ }
1355
+ return out;
1356
+ }
1357
+ function scan3(ctx) {
1358
+ const threshold = ctx.config.fileSize.threshold;
1359
+ const budgets = loadBudgets3(resolve8(ctx.repoRoot, ctx.config.fileSize.budgetsPath));
1360
+ const failures = [];
1361
+ const seen = /* @__PURE__ */ new Set();
1362
+ for (const { rel, lines } of collectFiles(ctx)) {
1363
+ seen.add(rel);
1364
+ const explicit = budgets[rel];
1365
+ if (explicit !== void 0) {
1366
+ if (lines > explicit) {
1367
+ failures.push({
1368
+ path: rel,
1369
+ lines,
1370
+ budget: explicit,
1371
+ reason: `exceeds frozen budget (${lines} > ${explicit}); refactor instead of raising the budget`
1372
+ });
1373
+ }
1374
+ } else if (lines > threshold) {
1375
+ failures.push({
1376
+ path: rel,
1377
+ lines,
1378
+ budget: threshold,
1379
+ reason: `exceeds default threshold (${lines} > ${threshold}); split the file or add a justified budget entry`
1380
+ });
1381
+ }
1382
+ }
1383
+ const staleBudgetEntries = Object.keys(budgets).filter((p) => !seen.has(p));
1384
+ return { failures, staleBudgetEntries };
1385
+ }
1386
+ function seedBudgets2(ctx) {
1387
+ const threshold = ctx.config.fileSize.threshold;
1388
+ const budgets = {};
1389
+ for (const { rel, lines } of collectFiles(ctx)) {
1390
+ if (lines > threshold) budgets[rel] = lines;
1391
+ }
1392
+ return Object.fromEntries(Object.entries(budgets).sort(([a], [b]) => a.localeCompare(b)));
1393
+ }
1394
+ function writeSeed3(ctx) {
1395
+ const path = resolve8(ctx.repoRoot, ctx.config.fileSize.budgetsPath);
1396
+ const budgets = seedBudgets2(ctx);
1397
+ const file = {
1398
+ _comment: "Grandfathered per-file line budgets. Files over the threshold are frozen at their current line count; the ratchet only goes down. Refactor and lower these; never raise them.",
1399
+ budgets
1400
+ };
1401
+ writeFileSync5(path, `${JSON.stringify(file, null, 2)}
1402
+ `);
1403
+ return { path, count: Object.keys(budgets).length };
1404
+ }
1405
+ function tightest(ctx) {
1406
+ const threshold = ctx.config.fileSize.threshold;
1407
+ const budgets = loadBudgets3(resolve8(ctx.repoRoot, ctx.config.fileSize.budgetsPath));
1408
+ let best;
1409
+ for (const { rel, lines } of collectFiles(ctx)) {
1410
+ const budget = budgets[rel] ?? threshold;
1411
+ const headroom = budget - lines;
1412
+ if (!best || headroom < best.headroom) best = { path: rel, lines, budget, headroom };
1413
+ }
1414
+ return best;
1415
+ }
1416
+ function runFileSizes(ctx, init = false) {
1417
+ if (init) {
1418
+ const { path, count } = writeSeed3(ctx);
1419
+ console.log(`file-size guard: seeded ${count} grandfathered budget(s) \u2192 ${path}`);
1420
+ return 0;
1421
+ }
1422
+ const { failures, staleBudgetEntries } = scan3(ctx);
1423
+ if (staleBudgetEntries.length > 0) {
1424
+ console.error(`${ctx.config.fileSize.budgetsPath} has entries for files that no longer exist:`);
1425
+ for (const p of staleBudgetEntries) console.error(` - ${p}`);
1426
+ console.error("Remove these entries to keep the budget honest.\n");
1427
+ }
1428
+ if (failures.length > 0) {
1429
+ console.error("File-size guard failed:");
1430
+ for (const f of failures) console.error(` ${f.path}: ${f.reason}`);
1431
+ console.error(
1432
+ `
1433
+ The ratchet only goes down \u2014 refactor large files into smaller modules rather than raising their budget.`
1434
+ );
1435
+ return 1;
1436
+ }
1437
+ if (staleBudgetEntries.length > 0) return 1;
1438
+ const t = tightest(ctx);
1439
+ if (t) {
1440
+ console.log(
1441
+ `SCORE: file-size \u2014 tightest ${t.path} ${t.lines}/${t.budget} (${t.headroom} to spare)`
1442
+ );
1443
+ }
1444
+ console.log("File-size guard ok.");
1445
+ return 0;
1446
+ }
1447
+
1448
+ // src/init.ts
1449
+ import { spawnSync as spawnSync4 } from "child_process";
1450
+ import { existsSync as existsSync11, mkdirSync, writeFileSync as writeFileSync6 } from "fs";
1451
+ import { basename as basename2, dirname as dirname4, join as join6, resolve as resolve9 } from "path";
1452
+ import { satisfies as satisfies2 } from "semver";
1453
+
1454
+ // src/init-detect.ts
1455
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
1456
+ import { dirname as dirname3, join as join5, relative as relative6 } from "path";
1457
+ import { createRequire } from "module";
1458
+ import { minVersion, satisfies, valid, validRange } from "semver";
1459
+ import { parse as parse2 } from "yaml";
1460
+ var excluded = [
1461
+ ...DEFAULT_CONFIG.excludeDirSegments,
1462
+ ".git",
1463
+ ".worktrees",
1464
+ ".next",
1465
+ ".yarn",
1466
+ ".cache",
1467
+ "gates"
1468
+ ];
1469
+ function readJson(file) {
1470
+ return JSON.parse(readFileSync11(file, "utf8"));
1471
+ }
1472
+ function detectManager(cwd, pkg) {
1473
+ const declared = pkg.packageManager?.split("@")[0];
1474
+ if (declared) {
1475
+ if (["pnpm", "npm", "yarn", "bun"].includes(declared)) return declared;
1476
+ throw new Error(`Unsupported package manager: ${declared}`);
1477
+ }
1478
+ const locks = [
1479
+ ["pnpm-lock.yaml", "pnpm"],
1480
+ ["pnpm-workspace.yaml", "pnpm"],
1481
+ ["yarn.lock", "yarn"],
1482
+ ["bun.lock", "bun"],
1483
+ ["bun.lockb", "bun"],
1484
+ ["package-lock.json", "npm"]
1485
+ ];
1486
+ return locks.find(([file]) => existsSync10(join5(cwd, file)))?.[1] ?? "npm";
1487
+ }
1488
+ function discoverProjects(cwd, pkg) {
1489
+ const workspaceFile = join5(cwd, "pnpm-workspace.yaml");
1490
+ const workspace = existsSync10(workspaceFile) ? parse2(readFileSync11(workspaceFile, "utf8")) : void 0;
1491
+ const patterns = workspace?.packages ?? (Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages) ?? [];
1492
+ if (patterns.some((p) => /[{}[\]?]/.test(p))) {
1493
+ throw new Error(
1494
+ "Workspace discovery supports literal paths, * and ** globs, and ! exclusions. Expand other workspace patterns before running init."
1495
+ );
1496
+ }
1497
+ const included = patterns.filter((p) => !p.startsWith("!")).map((p) => p.replace(/^\.\//, "").replace(/\/$/, ""));
1498
+ const omitted = patterns.filter((p) => p.startsWith("!")).map((p) => p.slice(1).replace(/^\.\//, "").replace(/\/$/, ""));
1499
+ const projects = [{ dir: ".", pkg }];
1500
+ if (included.length) {
1501
+ for (const file of walk(cwd, excluded)) {
1502
+ if (!file.endsWith(`${process.platform === "win32" ? "\\" : "/"}package.json`)) continue;
1503
+ const dir = relative6(cwd, dirname3(file)).replaceAll("\\", "/");
1504
+ if (dir && matchesAny(dir, included) && !matchesAny(dir, omitted))
1505
+ projects.push({ dir, pkg: readJson(file) });
1506
+ }
1507
+ }
1508
+ return {
1509
+ projects: projects.sort((a, b) => a.dir.localeCompare(b.dir)),
1510
+ ignoredPatterns: omitted.map((pattern) => `${pattern}/**`)
1511
+ };
1512
+ }
1513
+ function dependency(pkg, name) {
1514
+ return pkg.devDependencies?.[name] ?? pkg.dependencies?.[name];
1515
+ }
1516
+ function dependencyRange(cwd, pkg, name) {
1517
+ const declared = dependency(pkg, name);
1518
+ if (!declared) return void 0;
1519
+ const workspaceFile = join5(cwd, "pnpm-workspace.yaml");
1520
+ if (declared.startsWith("catalog:") && existsSync10(workspaceFile)) {
1521
+ const data = parse2(readFileSync11(workspaceFile, "utf8"));
1522
+ const catalog = declared.slice("catalog:".length);
1523
+ return catalog ? data?.catalogs?.[catalog]?.[name] : data?.catalog?.[name];
1524
+ }
1525
+ return declared;
1526
+ }
1527
+ function dependencyVersion(cwd, project, name) {
1528
+ const range = dependencyRange(cwd, project.pkg, name);
1529
+ if (!range) return void 0;
1530
+ try {
1531
+ const require2 = createRequire(join5(cwd, project.dir, "package.json"));
1532
+ const version = readJson(require2.resolve(`${name}/package.json`)).version;
1533
+ if (valid(version) && (!validRange(range) || satisfies(version, range))) return version;
1534
+ } catch {
1535
+ }
1536
+ try {
1537
+ return minVersion(range)?.version;
1538
+ } catch {
1539
+ return void 0;
1540
+ }
1541
+ }
1542
+ function requireCompatible(cwd, pkg, name, range) {
1543
+ if (!dependency(pkg, name)) return;
1544
+ const version = dependencyVersion(cwd, { dir: ".", pkg }, name);
1545
+ if (!version || !satisfies(version, range)) {
1546
+ throw new Error(
1547
+ `${name} must satisfy ${range}. Upgrade or resolve the existing dependency before init, or use --no-design-system.`
1548
+ );
1549
+ }
1550
+ }
1551
+
1552
+ // src/design-system.ts
1553
+ var designSystemRules = {
1554
+ "shadcn/no-restyle": ["error", { allow: ["layout"] }],
1555
+ "shadcn/no-raw-colors": "error",
1556
+ "shadcn/no-arbitrary-values": "error",
1557
+ "shadcn/no-inline-styles": "error",
1558
+ "shadcn/no-unknown-classes": "error",
1559
+ "shadcn/require-static-classes": "error"
1560
+ };
1561
+ var componentDefinitionRules = { "shadcn/no-restyle": "off" };
1562
+
1563
+ // src/init-design-system.ts
1564
+ var ESLINT_CONFIG = "eslint.design-system.config.mjs";
1565
+ var OXLINT_CONFIG = ".oxlintrc.design-system.json";
1566
+ function eslintConfig(files, componentFiles, ignoredProjects = []) {
1567
+ return `// Generated by repo-gates init. Edit these scopes and rule overrides for your project.
1568
+ // Rule docs: https://github.com/shadcn-ui/lint
1569
+ import { plugin as shadcn } from "@shadcn/lint";
1570
+ import tsParser from "@typescript-eslint/parser";
1571
+ import { designSystemRules, componentDefinitionRules } from "@fantastic.dev/repo-gates/design-system";
1572
+
1573
+ export default [
1574
+ { ignores: ${JSON.stringify(["**/node_modules/**", "**/dist/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.git/**", "**/.worktrees/**", ...ignoredProjects])} },
1575
+ {
1576
+ files: ${JSON.stringify(files)},
1577
+ languageOptions: { parser: tsParser, parserOptions: { ecmaFeatures: { jsx: true } } },
1578
+ plugins: { shadcn },
1579
+ rules: {
1580
+ ...designSystemRules,
1581
+ // Override individual defaults here, e.g. "shadcn/no-inline-styles": "off".
1582
+ },
1583
+ },
1584
+ {
1585
+ files: ${JSON.stringify(componentFiles)},
1586
+ rules: { ...componentDefinitionRules },
1587
+ },
1588
+ ];
1589
+ `;
1590
+ }
1591
+ function oxlintConfig(files, componentFiles, ignoredProjects = []) {
1592
+ return JSON.stringify(
1593
+ {
1594
+ $schema: "./node_modules/oxlint/configuration_schema.json",
1595
+ categories: { correctness: "off" },
1596
+ jsPlugins: ["@shadcn/lint"],
1597
+ ignorePatterns: [
1598
+ "**/node_modules/**",
1599
+ "**/dist/**",
1600
+ "**/out/**",
1601
+ "**/.next/**",
1602
+ "**/coverage/**",
1603
+ "**/.git/**",
1604
+ "**/.worktrees/**",
1605
+ ...ignoredProjects
1606
+ ],
1607
+ rules: {},
1608
+ overrides: [
1609
+ { files, rules: designSystemRules },
1610
+ { files: componentFiles, rules: componentDefinitionRules }
1611
+ ]
1612
+ },
1613
+ null,
1614
+ 2
1615
+ ) + "\n";
1616
+ }
1617
+
1618
+ // src/init.ts
1619
+ function parseInitOptions(args) {
1620
+ const options = {};
1621
+ for (let i = 0; i < args.length; i++) {
1622
+ const arg = args[i];
1623
+ if (arg === "--skip-install") options.install = false;
1624
+ else if (arg === "--no-design-system") options.designSystem = false;
1625
+ else if (arg === "--no-shadscan") options.shadscan = false;
1626
+ else if (arg === "--shadscan-floor" || arg.startsWith("--shadscan-floor=")) {
1627
+ const raw = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++i];
1628
+ if (!raw || !/^\d+$/.test(raw) || Number(raw) > 100) {
1629
+ throw new Error("--shadscan-floor must be an integer from 0 to 100.");
1630
+ }
1631
+ options.shadscanFloor = Number(raw);
1632
+ } else throw new Error(`Unknown init option: ${arg}`);
1633
+ }
1634
+ return options;
1635
+ }
1636
+ function writeJson(file, value) {
1637
+ writeFileSync6(file, `${JSON.stringify(value, null, 2)}
1638
+ `);
1639
+ }
1640
+ function shellQuote(value) {
1641
+ if (!/^[a-zA-Z0-9_./ @+-]+$/.test(value)) {
1642
+ throw new Error(
1643
+ "A Shadscan workspace path contains shell-special characters. Define check:shadscan manually or use --no-shadscan."
1644
+ );
1645
+ }
1646
+ return `"${value === "." ? "." : `./${value}`}"`;
1647
+ }
1648
+ function runInit(cwd, options = {}) {
1649
+ try {
1650
+ return initialize(cwd, options);
1651
+ } catch (error) {
1652
+ console.error(`repo-gates init: ${error instanceof Error ? error.message : String(error)}`);
1653
+ return 1;
1654
+ }
1655
+ }
1656
+ function initialize(cwd, options) {
1657
+ const packagePath = join6(cwd, "package.json");
1658
+ if (!existsSync11(packagePath))
1659
+ throw new Error("Run init from a repository root containing package.json.");
1660
+ const floor = options.shadscanFloor ?? 80;
1661
+ if (!Number.isInteger(floor) || floor < 0 || floor > 100)
1662
+ throw new Error("--shadscan-floor must be an integer from 0 to 100.");
1663
+ const pkg = readJson(packagePath);
1664
+ const configPath = join6(cwd, "repo-gates.config.json");
1665
+ const config = existsSync11(configPath) ? readJson(configPath) : {};
1666
+ const manager = detectManager(cwd, pkg);
1667
+ const { projects, ignoredPatterns } = discoverProjects(cwd, pkg);
1668
+ const scripts = { ...pkg.scripts };
1669
+ const added = [];
1670
+ const addScript = (name, command) => {
1671
+ if (scripts[name] !== void 0) return;
1672
+ scripts[name] = command;
1673
+ added.push(name);
1674
+ };
1675
+ const devDependencies = { ...pkg.devDependencies };
1676
+ const addDependency = (name, version) => {
1677
+ if (!dependency(pkg, name)) devDependencies[name] = version;
1678
+ };
1679
+ const filesToWrite = /* @__PURE__ */ new Map();
1680
+ const notices = [];
1681
+ const tailwindProjects = projects.filter((project) => {
1682
+ const own = dependencyVersion(cwd, project, "tailwindcss");
1683
+ const root = dependencyVersion(cwd, { dir: ".", pkg }, "tailwindcss");
1684
+ return (own ?? root)?.startsWith("4.");
1685
+ });
1686
+ const shadcnProjects = projects.filter(
1687
+ (project) => existsSync11(join6(cwd, project.dir, "components.json"))
1688
+ );
1689
+ const useDesign = options.designSystem !== false && tailwindProjects.length > 0;
1690
+ const useShadscan = options.shadscan !== false && shadcnProjects.length > 0;
1691
+ if (useDesign && !scripts["check:design-system"]) {
1692
+ if (!satisfies2(process.versions.node, ">=20.19"))
1693
+ throw new Error(
1694
+ "Design-system setup requires Node >=20.19. Upgrade Node or use --no-design-system."
1695
+ );
1696
+ const useOxlint = Boolean(dependency(pkg, "oxlint") || existsSync11(join6(cwd, ".oxlintrc.json"))) && !dependency(pkg, "eslint");
1697
+ requireCompatible(cwd, pkg, "@shadcn/lint", ">=0.1.5 <0.2.0");
1698
+ if (useOxlint) {
1699
+ if (!satisfies2(process.versions.node, "^20.19.0 || >=22.12.0"))
1700
+ throw new Error("Oxlint requires Node ^20.19.0 or >=22.12.0.");
1701
+ requireCompatible(cwd, pkg, "oxlint", ">=1.80.0");
1702
+ addDependency("oxlint", "1.80.0");
1703
+ } else {
1704
+ requireCompatible(cwd, pkg, "eslint", ">=9.30.0");
1705
+ requireCompatible(cwd, pkg, "@typescript-eslint/parser", ">=8.40.0");
1706
+ addDependency("eslint", "9.39.4");
1707
+ addDependency("@typescript-eslint/parser", "8.40.0");
1708
+ }
1709
+ addDependency("@shadcn/lint", "0.1.5");
1710
+ const scopes = tailwindProjects.map(
1711
+ ({ dir }) => dir === "." ? "**/*.{js,jsx,ts,tsx}" : `${dir}/**/*.{js,jsx,ts,tsx}`
1712
+ );
1713
+ const componentScopes = tailwindProjects.flatMap(({ dir }) => {
1714
+ const prefix = dir === "." ? "" : `${dir}/`;
1715
+ return [
1716
+ `${prefix}src/components/ui/**`,
1717
+ `${prefix}components/ui/**`,
1718
+ ...basename2(dir) === "ui" ? [`${prefix}src/components/**`] : []
1719
+ ];
1720
+ });
1721
+ const ignoredProjects = [
1722
+ ...ignoredPatterns,
1723
+ ...projects.filter((p) => p.dir !== "." && !tailwindProjects.includes(p)).map((p) => `${p.dir}/**`)
1724
+ ];
1725
+ const file = useOxlint ? OXLINT_CONFIG : ESLINT_CONFIG;
1726
+ if (!existsSync11(join6(cwd, file)))
1727
+ filesToWrite.set(
1728
+ file,
1729
+ useOxlint ? oxlintConfig(scopes, componentScopes, ignoredProjects) : eslintConfig(scopes, componentScopes, ignoredProjects)
1730
+ );
1731
+ else
1732
+ notices.push(
1733
+ `Preserved ${file}; verify its rule policy and component scopes. Existing rules were not changed.`
1734
+ );
1735
+ addScript(
1736
+ "check:design-system",
1737
+ `${useOxlint ? "oxlint" : "eslint"} --config ${file} . --max-warnings 0`
1738
+ );
1739
+ if (filesToWrite.has(file))
1740
+ notices.push(
1741
+ `Design-system rules: all six enabled in ${file}. Check component-directory scopes and theme discovery. https://github.com/shadcn-ui/lint`
1742
+ );
1743
+ }
1744
+ if (useShadscan && !scripts["check:shadscan"]) {
1745
+ if (dependency(pkg, "@shadscan/cli") && dependencyRange(cwd, pkg, "@shadscan/cli") !== "0.7.0") {
1746
+ throw new Error(
1747
+ "Existing @shadscan/cli differs from the supported pin 0.7.0. Define your own check:shadscan script or use --no-shadscan."
1748
+ );
1749
+ }
1750
+ addDependency("@shadscan/cli", "0.7.0");
1751
+ addScript(
1752
+ "check:shadscan",
1753
+ shadcnProjects.map(
1754
+ ({ dir }) => `shadscan ${shellQuote(dir)} --json --fail-under ${floor} --no-roast --no-interactive`
1755
+ ).join(" && ")
1756
+ );
1757
+ notices.push(
1758
+ `Shadscan floor: ${floor}/100 for ${shadcnProjects.map((p) => p.dir).join(", ")}. This is a starting policy, not a measured baseline; raise it as findings are fixed.`
1759
+ );
1760
+ }
1761
+ addScript("check:all", "repo-gates check-all");
1762
+ for (const [name, command] of Object.entries({
1763
+ "check:size": "check-size",
1764
+ "check:debt": "check-debt",
1765
+ "check:circular": "check-circular",
1766
+ "check:secrets": "check-secrets",
1767
+ "check:ci-parity": "check-ci-parity"
1768
+ }))
1769
+ addScript(name, `repo-gates ${command}`);
1770
+ const agents = projects.map(({ dir }) => dir === "." ? "AGENTS.md" : `${dir}/AGENTS.md`).filter((path) => existsSync11(join6(cwd, path)));
1771
+ if (agents.length || config.agents?.targets?.length)
1772
+ addScript("check:agents", "repo-gates check-agents");
1773
+ if (scripts["test:coverage"]) addScript("check:coverage", "repo-gates check-coverage");
1774
+ if (config.docsCoverage?.surfaces?.length)
1775
+ addScript("check:docs-coverage", "repo-gates check-docs-coverage");
1776
+ if (config.bundleSize?.targets?.length)
1777
+ addScript("check:bundle-size", "repo-gates check-bundle-size");
1778
+ config.runner ??= `${manager} run`;
1779
+ config.scanRoots ??= ["."];
1780
+ config.sourceExtensions ??= [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
1781
+ config.excludeDirSegments ??= excluded;
1782
+ config.coverage = {
1783
+ summaryGlobs: projects.map(
1784
+ ({ dir }) => dir === "." ? "coverage/coverage-summary.json" : `${dir}/coverage/coverage-summary.json`
1785
+ ),
1786
+ ...config.coverage
1787
+ };
1788
+ config.agents = { targets: agents, runnerCommand: manager, ...config.agents };
1789
+ if (config.gates) {
1790
+ const known = new Set(config.gates.map((g) => g.name));
1791
+ const additions = DEFAULT_CONFIG.gates.filter((g) => added.includes(g.name) && !known.has(g.name)).map((g) => ({ ...g, conditional: false }));
1792
+ const parityIndex = config.gates.findIndex((g) => g.name === "check:ci-parity");
1793
+ config.gates.splice(parityIndex < 0 ? config.gates.length : parityIndex, 0, ...additions);
1794
+ } else {
1795
+ config.gates = DEFAULT_CONFIG.gates.filter((g) => scripts[g.name] !== void 0).map((g) => ({ ...g, conditional: false }));
1796
+ }
1797
+ pkg.scripts = scripts;
1798
+ if (Object.keys(devDependencies).length) pkg.devDependencies = devDependencies;
1799
+ for (const [file, content] of filesToWrite) writeFileSync6(join6(cwd, file), content);
1800
+ writeJson(packagePath, pkg);
1801
+ writeJson(configPath, config);
1802
+ mkdirSync(join6(cwd, "gates"), { recursive: true });
1803
+ const ctx = loadContext(cwd);
1804
+ for (const [script, command, path, seed] of [
1805
+ ["check:size", "repo-gates check-size", ctx.config.fileSize.budgetsPath, runFileSizes],
1806
+ ["check:debt", "repo-gates check-debt", ctx.config.debt.allowlistPath, runDebtMarkers],
1807
+ [
1808
+ "check:circular",
1809
+ "repo-gates check-circular",
1810
+ ctx.config.circular.allowlistPath,
1811
+ runCircularImports
1812
+ ]
1813
+ ]) {
1814
+ const absolute = resolve9(cwd, path);
1815
+ if (scripts[script] === command && !existsSync11(absolute)) {
1816
+ mkdirSync(dirname4(absolute), { recursive: true });
1817
+ if (seed(ctx, true) !== 0)
1818
+ throw new Error(`Could not seed ${path}. Fix the error and rerun init.`);
1819
+ }
1820
+ }
1821
+ console.log(
1822
+ `Configured ${added.length} new script(s). Existing scripts, lint configs, and baselines were preserved.`
1823
+ );
1824
+ for (const notice2 of notices) console.log(notice2);
1825
+ const missing = ["lint", "format:check", "typecheck", "test"].filter((name) => !scripts[name]);
1826
+ if (missing.length)
1827
+ console.log(
1828
+ `Not configured: ${missing.join(", ")}. Add real project commands and entries to repo-gates.config.json to enable them.`
1829
+ );
1830
+ if (scripts["check:coverage"])
1831
+ console.log(
1832
+ "Coverage is enabled. Ensure test:coverage writes json-summary reports; run repo-gates check-coverage --init to seed floors."
1833
+ );
1834
+ console.log(
1835
+ "Secret scanning is enabled with no automatic allowlist. Review any findings from check:all."
1836
+ );
1837
+ if (options.install !== false && (useDesign || useShadscan)) {
1838
+ console.log(`Installing dependencies with ${manager} install...`);
1839
+ const result = spawnSync4(manager, ["install"], {
1840
+ cwd,
1841
+ stdio: "inherit",
1842
+ shell: process.platform === "win32"
1843
+ });
1844
+ if (result.error || result.status !== 0) {
1845
+ console.error(
1846
+ `Dependency installation failed. Files were saved; run ${manager} install or rerun init. ${result.error?.message ?? ""}`
1847
+ );
1848
+ return 1;
1849
+ }
1850
+ } else if (options.install === false && (useDesign || useShadscan)) {
1851
+ console.log(
1852
+ `Dependencies were recorded but not installed. Run ${manager} install before checking.`
1853
+ );
1854
+ }
1855
+ if (scripts["check:all"] !== "repo-gates check-all") {
1856
+ console.log(
1857
+ "Existing check:all was preserved. Ensure it invokes repo-gates check-all so the new gates run."
1858
+ );
1859
+ }
1860
+ console.log(
1861
+ `Run ${config.runner} check:all locally and in CI. Commit package.json, the lockfile, generated lint config, repo-gates.config.json, and gates/.`
1862
+ );
1863
+ return 0;
1864
+ }
1865
+
1866
+ // src/report.ts
1867
+ import { appendFileSync, existsSync as existsSync12, readFileSync as readFileSync12 } from "fs";
1868
+ import { resolve as resolve10 } from "path";
1869
+ function attr(tag, name) {
1870
+ return tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
1871
+ }
1872
+ function parseJUnit(xml) {
1873
+ const cases = [];
1874
+ for (const match of xml.matchAll(/<testcase\b[^>]*\/?>/g)) {
1875
+ const tag = match[0];
1876
+ const timeSeconds = Number.parseFloat(attr(tag, "time") ?? "0");
1877
+ if (!Number.isFinite(timeSeconds)) continue;
1878
+ cases.push({
1879
+ name: attr(tag, "name") ?? "",
1880
+ classname: attr(tag, "classname") ?? "",
1881
+ file: attr(tag, "file") ?? attr(tag, "classname") ?? "",
1882
+ timeSeconds
1883
+ });
1884
+ }
1885
+ return cases;
1886
+ }
1887
+ function fmtSeconds(s) {
1888
+ return s >= 1 ? `${s.toFixed(2)}s` : `${(s * 1e3).toFixed(1)}ms`;
1889
+ }
1890
+ function collectTiming(ctx) {
1891
+ const cases = [];
1892
+ let files = 0;
1893
+ for (const glob of ctx.config.report.junitGlobs) {
1894
+ for (const path of expandGlob(ctx.repoRoot, glob)) {
1895
+ files++;
1896
+ cases.push(...parseJUnit(readFileSync12(path, "utf8")));
1897
+ }
1898
+ }
1899
+ const totalSeconds = cases.reduce((a, c) => a + c.timeSeconds, 0);
1900
+ return { report: { totalSeconds, totalTests: cases.length, cases }, files };
1901
+ }
1902
+ function formatTiming(report, topN) {
1903
+ const slowest = [...report.cases].sort((a, b) => b.timeSeconds - a.timeSeconds).slice(0, topN);
1904
+ const lines = [
1905
+ "## Test timing",
1906
+ "",
1907
+ `**Total:** ${fmtSeconds(report.totalSeconds)} across ${report.totalTests} tests.`,
1908
+ "",
1909
+ `### Slowest ${slowest.length} tests`,
1910
+ "",
1911
+ "| Time | Test | File |",
1912
+ "| ---: | --- | --- |"
1913
+ ];
1914
+ for (const c of slowest) {
1915
+ const name = `${c.classname} \u203A ${c.name}`.replaceAll("|", "\\|");
1916
+ lines.push(`| ${fmtSeconds(c.timeSeconds)} | ${name} | \`${c.file}\` |`);
1917
+ }
1918
+ return `${lines.join("\n")}
1919
+ `;
1920
+ }
1921
+ function pct2(n) {
1922
+ return `${n.toFixed(1)}%`;
1923
+ }
1924
+ function formatQualityMetrics(ctx) {
1925
+ const rows = [];
1926
+ try {
1927
+ const per = collectPerPackage(ctx);
1928
+ const min = lowest(per);
1929
+ rows.push([
1930
+ "Coverage (lowest pkg)",
1931
+ min ? `${pct2(min.lines)} lines \u2014 \`${min.pkg}\` (${per.length} pkgs)` : "\u2014"
1932
+ ]);
1933
+ } catch {
1934
+ rows.push(["Coverage (lowest pkg)", "\u2014"]);
1935
+ }
1936
+ try {
1937
+ const t = tightest(ctx);
1938
+ rows.push([
1939
+ "File size (tightest)",
1940
+ t ? `\`${t.path}\` ${t.lines}/${t.budget} (${t.headroom} to spare)` : "\u2014"
1941
+ ]);
1942
+ } catch {
1943
+ rows.push(["File size (tightest)", "\u2014"]);
1944
+ }
1945
+ try {
1946
+ const { untracked } = scan2(ctx);
1947
+ rows.push(["Debt markers (untracked)", String(untracked.length)]);
1948
+ } catch {
1949
+ rows.push(["Debt markers (untracked)", "\u2014"]);
1950
+ }
1951
+ try {
1952
+ const path = resolve10(ctx.repoRoot, ctx.config.bundleSize.budgetsPath);
1953
+ if (existsSync12(path)) {
1954
+ const budgets = JSON.parse(readFileSync12(path, "utf8"));
1955
+ for (const t of ctx.config.bundleSize.targets) {
1956
+ const b = budgets[t.name];
1957
+ const gzip = b?.totals?.gzip ?? {};
1958
+ const total = Object.values(gzip).reduce((a, v) => a + v, 0);
1959
+ rows.push([`Bundle budget (${t.name})`, `${(total / 1024).toFixed(1)} KB gz`]);
1960
+ }
1961
+ }
1962
+ } catch {
1963
+ rows.push(["Bundle budget", "\u2014"]);
1964
+ }
1965
+ const lines = ["## Code-quality metrics", "", "| Metric | Current |", "| --- | --- |"];
1966
+ for (const [k, v] of rows) lines.push(`| ${k} | ${v} |`);
1967
+ return `${lines.join("\n")}
1968
+ `;
1969
+ }
1970
+ function emitSummary(markdown) {
1971
+ process.stdout.write(`${markdown}
1972
+ `);
1973
+ const summary = process.env.GITHUB_STEP_SUMMARY;
1974
+ if (summary) {
1975
+ try {
1976
+ appendFileSync(summary, `${markdown}
1977
+ `);
1978
+ } catch (err) {
1979
+ console.error(`report: could not write GITHUB_STEP_SUMMARY: ${err}`);
1980
+ }
1981
+ }
1982
+ }
1983
+ function runTestTiming(ctx) {
1984
+ const { report, files } = collectTiming(ctx);
1985
+ if (files === 0) {
1986
+ console.log("report:test-timing \u2014 no junit files found (run test:ci first); skipping.");
1987
+ return 0;
1988
+ }
1989
+ emitSummary(formatTiming(report, ctx.config.report.topN));
1990
+ return 0;
1991
+ }
1992
+ function runQualityMetrics(ctx) {
1993
+ emitSummary(formatQualityMetrics(ctx));
1994
+ return 0;
1995
+ }
1996
+
1997
+ // src/secrets.ts
1998
+ import { createHash } from "crypto";
1999
+ import { existsSync as existsSync13, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "fs";
2000
+ import { spawnSync as spawnSync5 } from "child_process";
2001
+ import { resolve as resolve11 } from "path";
2002
+ function parseAllowlist3(allowlistPath) {
2003
+ if (!existsSync13(allowlistPath)) return { entries: [], raw: [] };
2004
+ const raw = JSON.parse(readFileSync13(allowlistPath, "utf8"));
2005
+ const list = raw.allowlist;
2006
+ if (!Array.isArray(list)) {
2007
+ throw new Error(`${allowlistPath}: "allowlist" must be an array of "path:line" strings`);
2008
+ }
2009
+ const entries = [];
2010
+ const rawStrings = [];
2011
+ for (const item of list) {
2012
+ if (typeof item !== "string") {
2013
+ throw new Error(`${allowlistPath}: allowlist entries must be "path:line" strings`);
2014
+ }
2015
+ const idx = item.lastIndexOf(":");
2016
+ const path = idx < 0 ? "" : item.slice(0, idx);
2017
+ const line = idx < 0 ? Number.NaN : Number.parseInt(item.slice(idx + 1), 10);
2018
+ if (!path || !Number.isInteger(line) || line <= 0) {
2019
+ throw new Error(`${allowlistPath}: "${item}" is not a valid "path:line" entry`);
2020
+ }
2021
+ entries.push({ path, line });
2022
+ rawStrings.push(item);
2023
+ }
2024
+ return { entries, raw: rawStrings };
2025
+ }
2026
+ function listTrackedFiles(repoRoot) {
2027
+ const proc = spawnSync5("git", ["ls-files", "-z"], { cwd: repoRoot, encoding: "utf8" });
2028
+ if (proc.status !== 0 || !proc.stdout) return [];
2029
+ return proc.stdout.split("\0").filter((f) => f.length > 0);
2030
+ }
2031
+ function fingerprint(matched) {
2032
+ return createHash("sha256").update(matched).digest("hex").slice(0, 8);
2033
+ }
2034
+ function buildPatternRe(patterns) {
2035
+ return new RegExp(`(${patterns.join(")|(")})`);
2036
+ }
2037
+ function scan4(ctx) {
2038
+ const { excludeDirSegments, excludePathPrefixes, secrets } = ctx.config;
2039
+ const patternRe = buildPatternRe(secrets.patterns);
2040
+ const { entries: allowlist, raw } = parseAllowlist3(resolve11(ctx.repoRoot, secrets.allowlistPath));
2041
+ const allowSet = new Set(raw);
2042
+ const matchedAllow = /* @__PURE__ */ new Set();
2043
+ const untracked = [];
2044
+ for (const rel of listTrackedFiles(ctx.repoRoot)) {
2045
+ if (hasExtension(rel, secrets.binaryExtensions)) continue;
2046
+ if (excludeDirSegments.some((seg) => rel.split("/").includes(seg))) continue;
2047
+ if (excludePathPrefixes.some((prefix) => rel.startsWith(prefix))) continue;
2048
+ const abs = resolve11(ctx.repoRoot, rel);
2049
+ let content;
2050
+ try {
2051
+ content = readFileSync13(abs, "utf8");
2052
+ } catch {
2053
+ continue;
2054
+ }
2055
+ if (content.includes("\0")) continue;
2056
+ const lines = content.split("\n");
2057
+ for (let i = 0; i < lines.length; i++) {
2058
+ const line = lines[i] ?? "";
2059
+ const match = line.match(patternRe);
2060
+ if (!match) continue;
2061
+ const key = `${rel}:${i + 1}`;
2062
+ const finding = {
2063
+ path: rel,
2064
+ line: i + 1,
2065
+ pattern: match[0].length > 12 ? `${match[0].slice(0, 4)}\u2026` : "match",
2066
+ fingerprint: fingerprint(match[0])
2067
+ };
2068
+ if (allowSet.has(key)) matchedAllow.add(key);
2069
+ else untracked.push(finding);
2070
+ }
2071
+ }
2072
+ const staleAllowlistEntries = allowlist.map((e) => `${e.path}:${e.line}`).filter((key) => !matchedAllow.has(key));
2073
+ return { untracked, staleAllowlistEntries };
2074
+ }
2075
+ function seedAllowlist3(ctx) {
2076
+ const emptyCtx = {
2077
+ ...ctx,
2078
+ config: { ...ctx.config, secrets: { ...ctx.config.secrets, allowlistPath: "\0missing" } }
2079
+ };
2080
+ return scan4(emptyCtx).untracked.map((f) => `${f.path}:${f.line}`).sort((a, b) => a.localeCompare(b));
2081
+ }
2082
+ function writeSeed4(ctx) {
2083
+ const path = resolve11(ctx.repoRoot, ctx.config.secrets.allowlistPath);
2084
+ const allowlist = seedAllowlist3(ctx);
2085
+ const file = {
2086
+ _comment: "Grandfathered secret-shaped-string findings (path:line). Review each one BEFORE trusting this seed \u2014 it silences whatever it captures. Ratchet only goes down.",
2087
+ allowlist
2088
+ };
2089
+ writeFileSync7(path, `${JSON.stringify(file, null, 2)}
2090
+ `);
2091
+ return { path, count: allowlist.length };
2092
+ }
2093
+ function runSecrets(ctx, init = false) {
2094
+ if (init) {
2095
+ const { path, count } = writeSeed4(ctx);
2096
+ console.log(`secrets guard: seeded ${count} grandfathered finding(s) \u2192 ${path}`);
2097
+ console.log("Review every entry \u2014 the seed silences whatever it captures.");
2098
+ return 0;
2099
+ }
2100
+ const { untracked, staleAllowlistEntries } = scan4(ctx);
2101
+ if (staleAllowlistEntries.length > 0) {
2102
+ console.error(`${ctx.config.secrets.allowlistPath} has entries that no longer match:`);
2103
+ for (const k of staleAllowlistEntries) console.error(` - ${k}`);
2104
+ console.error("Remove these entries \u2014 the ratchet only goes down.\n");
2105
+ }
2106
+ if (untracked.length > 0) {
2107
+ console.error("Secret-shaped strings found (fingerprint only \u2014 never the matched text):");
2108
+ for (const f of untracked) {
2109
+ console.error(` ${f.path}:${f.line} fingerprint:${f.fingerprint}`);
2110
+ }
2111
+ console.error(
2112
+ "\nRotate and remove the credential, or \u2014 only if this is genuinely a false positive (a test fixture, a placeholder) \u2014 allowlist it via `repo-gates check-secrets --init`."
2113
+ );
2114
+ return 1;
2115
+ }
2116
+ if (staleAllowlistEntries.length > 0) return 1;
2117
+ const grandfathered = parseAllowlist3(resolve11(ctx.repoRoot, ctx.config.secrets.allowlistPath)).raw.length;
2118
+ console.log(`SCORE: secrets \u2014 ${grandfathered} grandfathered, 0 new`);
2119
+ console.log("Secrets guard ok.");
2120
+ return 0;
2121
+ }
2122
+
2123
+ // src/validate-agents.ts
2124
+ import { existsSync as existsSync14, readFileSync as readFileSync14 } from "fs";
2125
+ import { resolve as resolve12 } from "path";
2126
+ function extractFencedBashBlocks(markdown) {
2127
+ const blocks = [];
2128
+ const fence = /```(?:bash|sh|shell)\n([\s\S]*?)```/g;
2129
+ for (const m of markdown.matchAll(fence)) {
2130
+ if (m[1] !== void 0) blocks.push(m[1]);
2131
+ }
2132
+ return blocks;
2133
+ }
2134
+ function stripShellComments(block) {
2135
+ return block.split("\n").map((line) => {
2136
+ const hash = line.indexOf("#");
2137
+ return hash === -1 ? line : line.slice(0, hash);
2138
+ }).join("\n");
2139
+ }
2140
+ function escapeRegExp2(s) {
2141
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2142
+ }
2143
+ function extractScriptRefs(blocks, runnerCommand, ignoredSubcommands) {
2144
+ const ignored = new Set(ignoredSubcommands);
2145
+ const pm = escapeRegExp2(runnerCommand);
2146
+ const runRe = new RegExp(`\\b${pm}\\s+run\\s+([a-zA-Z0-9:_-]+)`, "g");
2147
+ const bareRe = new RegExp(`\\b${pm}\\s+([a-zA-Z0-9:][a-zA-Z0-9:_-]*)`, "g");
2148
+ const out = /* @__PURE__ */ new Set();
2149
+ for (const raw of blocks) {
2150
+ const block = stripShellComments(raw);
2151
+ for (const m of block.matchAll(runRe)) if (m[1]) out.add(m[1]);
2152
+ for (const m of block.matchAll(bareRe)) {
2153
+ const name = m[1];
2154
+ if (name && name !== "run" && !ignored.has(name)) out.add(name);
2155
+ }
2156
+ }
2157
+ return out;
2158
+ }
2159
+ function extractBacktickedPaths(markdown) {
2160
+ const paths = [];
2161
+ for (const m of markdown.matchAll(/`([^`\n]+)`/g)) {
2162
+ const token = (m[1] ?? "").trim();
2163
+ if (/^https?:\/\//.test(token)) continue;
2164
+ if (/\s/.test(token)) continue;
2165
+ if (token.startsWith("@")) continue;
2166
+ if (token.endsWith("...")) continue;
2167
+ if (/^\.[A-Za-z0-9]+$/.test(token)) continue;
2168
+ if (!/^[.A-Za-z0-9_][A-Za-z0-9_.\-/]*\/?$/.test(token)) continue;
2169
+ if (!token.includes("/")) continue;
2170
+ const cleaned = token.replace(/\/+$/, "");
2171
+ if (cleaned.includes("<") || cleaned.includes(">") || cleaned.includes("*")) continue;
2172
+ paths.push(cleaned);
2173
+ }
2174
+ return paths;
2175
+ }
2176
+ function validateAgents(ctx) {
2177
+ const { targets, knownMissingPaths, runnerCommand, ignoredSubcommands } = ctx.config.agents;
2178
+ const scripts = new Set(Object.keys(loadScripts(ctx.repoRoot)));
2179
+ const known = new Set(knownMissingPaths);
2180
+ const failures = [];
2181
+ for (const rel of targets) {
2182
+ const abs = resolve12(ctx.repoRoot, rel);
2183
+ if (!existsSync14(abs)) {
2184
+ failures.push({ file: rel, kind: "missing-doc", detail: `${rel} not found` });
2185
+ continue;
2186
+ }
2187
+ const src = readFileSync14(abs, "utf8");
2188
+ for (const name of extractScriptRefs(
2189
+ extractFencedBashBlocks(src),
2190
+ runnerCommand,
2191
+ ignoredSubcommands
2192
+ )) {
2193
+ if (!scripts.has(name)) {
2194
+ failures.push({
2195
+ file: rel,
2196
+ kind: "missing-script",
2197
+ detail: `\`${runnerCommand} ${name}\` referenced but not a package.json script`
2198
+ });
2199
+ }
2200
+ }
2201
+ for (const p of extractBacktickedPaths(src)) {
2202
+ if (known.has(p)) continue;
2203
+ if (!existsSync14(resolve12(ctx.repoRoot, p))) {
2204
+ failures.push({
2205
+ file: rel,
2206
+ kind: "missing-path",
2207
+ detail: `referenced path \`${p}\` does not exist`
2208
+ });
2209
+ }
2210
+ }
2211
+ }
2212
+ return failures;
2213
+ }
2214
+ function runValidateAgents(ctx) {
2215
+ const { targets } = ctx.config.agents;
2216
+ if (targets.length === 0) {
2217
+ console.log("check:agents \u2014 no agent docs configured; skipping.");
2218
+ return 0;
2219
+ }
2220
+ const failures = validateAgents(ctx);
2221
+ if (failures.length === 0) {
2222
+ console.log(`\u2713 agent-doc validation passed (${targets.join(", ")})`);
2223
+ return 0;
2224
+ }
2225
+ console.error("\u2717 agent-doc validation failed:");
2226
+ for (const f of failures) console.error(` [${f.kind}] ${f.file}: ${f.detail}`);
2227
+ return 1;
2228
+ }
2229
+
2230
+ // src/bin/repo-gates.ts
2231
+ var argv = process.argv.slice(2);
2232
+ var cmd = argv[0];
2233
+ var has = (flag) => argv.includes(flag);
2234
+ var USAGE = `repo-gates \u2014 config-driven repo quality gates
2235
+
2236
+ Usage: repo-gates <command> [options]
2237
+
2238
+ Commands:
2239
+ check-all [--bail] Run the whole gate manifest (quiet; --bail stops at first fail)
2240
+ check-size [--init] Per-file line-count ratchet (--init seeds baselines)
2241
+ check-debt [--init] Debt-marker (TODO/FIXME) ratchet (--init seeds the allowlist)
2242
+ check-circular [--init] Circular-import ratchet (--init seeds the allowlist)
2243
+ check-secrets [--init] Secret-shaped-string ratchet (--init seeds the allowlist)
2244
+ check-coverage [--init] Per-package coverage floors (--init seeds; --skip-run reuses summaries)
2245
+ check-bundle-size [--init] Bundle raw+gzip+chunk ratchet (--init seeds)
2246
+ check-agents Validate AGENTS.md script/path references resolve
2247
+ check-docs-coverage PR surface-vs-docs gate (no-op outside a PR context)
2248
+ check-ci-parity Fail if CI workflows drift from the check:all manifest
2249
+ report-test-timing Non-gating test-timing dashboard (\u2192 stdout / step summary)
2250
+ report-quality-metrics Non-gating code-quality dashboard
2251
+ init Configure applicable gates, scripts, baselines, and UI tooling
2252
+ --skip-install Write dependencies/configs without running the package manager
2253
+ --no-design-system Skip automatic @shadcn/lint setup
2254
+ --no-shadscan Skip automatic Shadscan setup
2255
+ --shadscan-floor <0-100> Initial score floor (default: 80)
2256
+
2257
+ Config: repo-gates.config.json at the repo root (partial overlay on built-in defaults).
2258
+ Docs: https://github.com/FantasticDevHQ/repo-gates`;
2259
+ if (!cmd || has("--help") || has("-h")) {
2260
+ console.log(USAGE);
2261
+ process.exit(cmd ? 0 : 1);
2262
+ }
2263
+ if (cmd === "init") {
2264
+ try {
2265
+ process.exitCode = runInit(process.cwd(), parseInitOptions(argv.slice(1)));
2266
+ } catch (error) {
2267
+ console.error(`repo-gates init: ${error instanceof Error ? error.message : String(error)}`);
2268
+ process.exitCode = 1;
2269
+ }
2270
+ } else {
2271
+ const ctx = loadContext();
2272
+ switch (cmd) {
2273
+ case "check-all":
2274
+ process.exitCode = runCheckAll(ctx, {
2275
+ verbose: process.env.CHECK_ALL_VERBOSE === "1",
2276
+ bail: has("--bail")
2277
+ });
2278
+ break;
2279
+ case "check-size":
2280
+ process.exitCode = runFileSizes(ctx, has("--init"));
2281
+ break;
2282
+ case "check-debt":
2283
+ process.exitCode = runDebtMarkers(ctx, has("--init"));
2284
+ break;
2285
+ case "check-circular":
2286
+ process.exitCode = runCircularImports(ctx, has("--init"));
2287
+ break;
2288
+ case "check-secrets":
2289
+ process.exitCode = runSecrets(ctx, has("--init"));
2290
+ break;
2291
+ case "check-coverage":
2292
+ process.exitCode = runCoverage(ctx, { init: has("--init"), skipRun: has("--skip-run") });
2293
+ break;
2294
+ case "check-bundle-size":
2295
+ process.exitCode = runBundleSize(ctx, has("--init"));
2296
+ break;
2297
+ case "check-agents":
2298
+ process.exitCode = runValidateAgents(ctx);
2299
+ break;
2300
+ case "check-docs-coverage":
2301
+ process.exitCode = await runDocsCoverage(ctx);
2302
+ break;
2303
+ case "check-ci-parity":
2304
+ process.exitCode = runCiParity(ctx);
2305
+ break;
2306
+ case "report-test-timing":
2307
+ process.exitCode = runTestTiming(ctx);
2308
+ break;
2309
+ case "report-quality-metrics":
2310
+ process.exitCode = runQualityMetrics(ctx);
2311
+ break;
2312
+ default:
2313
+ console.error(`repo-gates: unknown command '${cmd}'
2314
+ `);
2315
+ console.error(USAGE);
2316
+ process.exitCode = 1;
2317
+ }
2318
+ }