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