@monte3l/groundwork 0.0.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.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,1264 @@
1
+ /**
2
+ * Grades this project's TypeScript toolchain: the tsconfig `extends` chain,
3
+ * module/target settings, the ESLint config, the coverage gate, the wiring of
4
+ * `bin/lib/verify-steps.mjs`, and the toolchain pins in `package.json`.
5
+ * `gradeToolchain` reads everything once into a snapshot, then runs each rule
6
+ * over it. Structural rules catch wiring defects `tsc` and ESLint do not --
7
+ * a build project that emits nowhere, a verify step naming a script that does
8
+ * not exist -- and fail the gate. Rubric rules encode the floor official
9
+ * TypeScript / typescript-eslint guidance sets and only ever warn.
10
+ *
11
+ * Offline, read-only, and it never executes project code: `eslint.config.js`,
12
+ * `vitest.config.ts` and `verify-steps.mjs` are executable JavaScript, so
13
+ * their rules are regex scrapes over comment-stripped source, never
14
+ * evaluation. A scrape that finds the file but cannot tell the answer returns
15
+ * `{ checked: 0 }` rather than a failure -- absence, or a shape this module
16
+ * cannot read, is never a defect. The comment stripper is a JSONC scanner,
17
+ * not a JavaScript tokenizer; it can mis-handle a regex literal or a template
18
+ * string, which none of the graded baseline files contain.
19
+ *
20
+ * This file is the emitted twin of m3l-groundwork's own
21
+ * `packages/cli/src/toolchain/{rules,grade}.ts`; a parity test runs both over
22
+ * the real baseline and asserts identical findings.
23
+ */
24
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
26
+
27
+ /**
28
+ * Options TypeScript 6.0 deprecated. `removedIn` is the major that drops them
29
+ * (`outFile` and `moduleResolution: classic` already went in 6.0).
30
+ * https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html
31
+ * @public Exported for the parity test that compares this file with its TypeScript twin in
32
+ * m3l-groundwork; nothing else in a bootstrapped project imports it.
33
+ */
34
+ export const LEGACY_OPTIONS = [
35
+ {
36
+ option: "baseUrl",
37
+ label: "baseUrl",
38
+ deprecatedIn: 6,
39
+ removedIn: 7,
40
+ matches: (value) => value !== undefined,
41
+ },
42
+ {
43
+ option: "outFile",
44
+ label: "outFile",
45
+ deprecatedIn: 6,
46
+ removedIn: 6,
47
+ matches: (value) => value !== undefined,
48
+ },
49
+ {
50
+ option: "moduleResolution",
51
+ label: "moduleResolution: node",
52
+ deprecatedIn: 6,
53
+ removedIn: 7,
54
+ matches: (value) => ["node", "node10"].includes(lower(value)),
55
+ },
56
+ {
57
+ option: "moduleResolution",
58
+ label: "moduleResolution: classic",
59
+ deprecatedIn: 6,
60
+ removedIn: 6,
61
+ matches: (value) => lower(value) === "classic",
62
+ },
63
+ {
64
+ option: "target",
65
+ label: "target: es5",
66
+ deprecatedIn: 6,
67
+ removedIn: 7,
68
+ matches: (value) => lower(value) === "es5",
69
+ },
70
+ {
71
+ option: "downlevelIteration",
72
+ label: "downlevelIteration",
73
+ deprecatedIn: 6,
74
+ removedIn: 7,
75
+ matches: (value) => value === true,
76
+ },
77
+ {
78
+ option: "module",
79
+ label: "module: amd|umd|system|none",
80
+ deprecatedIn: 6,
81
+ removedIn: 7,
82
+ matches: (value) =>
83
+ ["amd", "umd", "system", "systemjs", "none"].includes(lower(value)),
84
+ },
85
+ {
86
+ option: "esModuleInterop",
87
+ label: "esModuleInterop: false",
88
+ deprecatedIn: 6,
89
+ removedIn: 7,
90
+ matches: (value) => value === false,
91
+ },
92
+ {
93
+ option: "allowSyntheticDefaultImports",
94
+ label: "allowSyntheticDefaultImports: false",
95
+ deprecatedIn: 6,
96
+ removedIn: 7,
97
+ matches: (value) => value === false,
98
+ },
99
+ {
100
+ option: "alwaysStrict",
101
+ label: "alwaysStrict: false",
102
+ deprecatedIn: 6,
103
+ removedIn: 7,
104
+ matches: (value) => value === false,
105
+ },
106
+ ];
107
+
108
+ /**
109
+ * The strict-family flags the baseline enables, plus `skipLibCheck`. Three
110
+ * omissions are deliberate and must not be "completed": `isolatedDeclarations`
111
+ * (the build project sets it alone; the tooling project includes tests/, which
112
+ * it does not tolerate), `noUnusedLocals`/`noUnusedParameters`
113
+ * (`@typescript-eslint/no-unused-vars` covers both with an `^_` escape hatch
114
+ * tsc's flags lack), and `forceConsistentCasingInFileNames` (defaults on).
115
+ * `allowUnreachableCode` is graded separately -- it must be `false`.
116
+ * @public Exported for the parity test that compares this file with its TypeScript twin in
117
+ * m3l-groundwork; nothing else in a bootstrapped project imports it.
118
+ */
119
+ export const STRICT_FLAGS = [
120
+ "strict",
121
+ "noUncheckedIndexedAccess",
122
+ "noImplicitOverride",
123
+ "exactOptionalPropertyTypes",
124
+ "verbatimModuleSyntax",
125
+ "isolatedModules",
126
+ "noFallthroughCasesInSwitch",
127
+ "noImplicitReturns",
128
+ "noPropertyAccessFromIndexSignature",
129
+ "noUncheckedSideEffectImports",
130
+ "skipLibCheck",
131
+ ];
132
+
133
+ /**
134
+ * The rubric categories, in report order.
135
+ * @public Exported for the parity test that compares this file with its TypeScript twin in
136
+ * m3l-groundwork; nothing else in a bootstrapped project imports it.
137
+ */
138
+ export const CATEGORIES = [
139
+ "tsconfig",
140
+ "modules",
141
+ "eslint",
142
+ "testing",
143
+ "gates",
144
+ "deps",
145
+ ];
146
+
147
+ const PROJECT_WALK_DEPTH = 6;
148
+ const MODERN_MODULES = new Set([
149
+ "nodenext",
150
+ "node16",
151
+ "node18",
152
+ "node20",
153
+ "preserve",
154
+ ]);
155
+ const MODERN_RESOLUTIONS = new Set(["nodenext", "node16", "bundler"]);
156
+ const MIN_TARGET_YEAR = 2022;
157
+ const PIN_PACKAGES = [
158
+ "typescript",
159
+ "eslint",
160
+ "typescript-eslint",
161
+ "@types/node",
162
+ ];
163
+ const FLAT_ESLINT = [
164
+ "eslint.config.js",
165
+ "eslint.config.mjs",
166
+ "eslint.config.cjs",
167
+ "eslint.config.ts",
168
+ "eslint.config.mts",
169
+ "eslint.config.cts",
170
+ ];
171
+ const LEGACY_ESLINT = /^\.eslintrc(\.(js|cjs|json|ya?ml))?$/;
172
+ const VITEST_CONFIGS = [
173
+ "vitest.config.ts",
174
+ "vitest.config.mts",
175
+ "vitest.config.cts",
176
+ "vitest.config.js",
177
+ "vitest.config.mjs",
178
+ "vitest.config.cjs",
179
+ ];
180
+ const PNPM_BUILTINS = new Set([
181
+ "exec",
182
+ "dlx",
183
+ "install",
184
+ "i",
185
+ "add",
186
+ "remove",
187
+ "update",
188
+ "audit",
189
+ "pack",
190
+ "publish",
191
+ "store",
192
+ "config",
193
+ "env",
194
+ "create",
195
+ ]);
196
+ const SKIP_DIR_NAMES = new Set([
197
+ "node_modules",
198
+ ".git",
199
+ "dist",
200
+ "build",
201
+ "coverage",
202
+ ".next",
203
+ ".turbo",
204
+ ".cache",
205
+ ".pnpm",
206
+ "out",
207
+ ".nx",
208
+ ]);
209
+
210
+ const isRecord = (value) =>
211
+ typeof value === "object" && value !== null && !Array.isArray(value);
212
+ const lower = (value) =>
213
+ typeof value === "string" ? value.toLowerCase() : value;
214
+
215
+ // --- reading the project ---------------------------------------------------
216
+
217
+ /** Bounded recursive listing: skips dependency/build dirs, stops at `maxDepth`. */
218
+ function walkBounded(root, maxDepth) {
219
+ const results = [];
220
+ const visit = (dir, depth) => {
221
+ if (depth > maxDepth) return;
222
+ let entries;
223
+ try {
224
+ entries = readdirSync(dir, { withFileTypes: true });
225
+ } catch {
226
+ return;
227
+ }
228
+ for (const entry of entries) {
229
+ if (entry.isDirectory() && SKIP_DIR_NAMES.has(entry.name)) continue;
230
+ const path = join(dir, entry.name);
231
+ results.push({
232
+ path,
233
+ relPath: relative(root, path).split("\\").join("/"),
234
+ isDirectory: entry.isDirectory(),
235
+ });
236
+ if (entry.isDirectory()) visit(path, depth + 1);
237
+ }
238
+ };
239
+ visit(root, 0);
240
+ return results;
241
+ }
242
+
243
+ /** Strips `//` and block comments and trailing commas, leaving string literals alone. */
244
+ function stripJsoncNoise(content) {
245
+ let result = "";
246
+ let inString = false;
247
+ let inLineComment = false;
248
+ let inBlockComment = false;
249
+ for (let i = 0; i < content.length; i++) {
250
+ const ch = content[i];
251
+ const next = content[i + 1];
252
+ if (inLineComment) {
253
+ if (ch === "\n") {
254
+ inLineComment = false;
255
+ result += ch;
256
+ }
257
+ continue;
258
+ }
259
+ if (inBlockComment) {
260
+ if (ch === "*" && next === "/") {
261
+ inBlockComment = false;
262
+ i++;
263
+ }
264
+ continue;
265
+ }
266
+ if (inString) {
267
+ result += ch;
268
+ if (ch === "\\") {
269
+ result += next ?? "";
270
+ i++;
271
+ continue;
272
+ }
273
+ if (ch === '"') inString = false;
274
+ continue;
275
+ }
276
+ if (ch === '"') {
277
+ inString = true;
278
+ result += ch;
279
+ continue;
280
+ }
281
+ if (ch === "/" && next === "/") {
282
+ inLineComment = true;
283
+ i++;
284
+ continue;
285
+ }
286
+ if (ch === "/" && next === "*") {
287
+ inBlockComment = true;
288
+ i++;
289
+ continue;
290
+ }
291
+ result += ch;
292
+ }
293
+ return result.replace(/,(\s*[}\]])/g, "$1");
294
+ }
295
+
296
+ function readJsonc(path) {
297
+ if (!existsSync(path)) return { ok: false, error: `${path} does not exist` };
298
+ try {
299
+ return {
300
+ ok: true,
301
+ value: JSON.parse(stripJsoncNoise(readFileSync(path, "utf8"))),
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ ok: false,
306
+ error: error instanceof Error ? error.message : String(error),
307
+ };
308
+ }
309
+ }
310
+
311
+ /** File text with comments stripped, or `undefined` when unreadable. */
312
+ function readSource(path) {
313
+ try {
314
+ return stripJsoncNoise(readFileSync(path, "utf8"));
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ /** File text exactly as written, or `undefined` when unreadable. */
321
+ function readRaw(path) {
322
+ try {
323
+ return readFileSync(path, "utf8");
324
+ } catch {
325
+ return undefined;
326
+ }
327
+ }
328
+
329
+ function isFile(path) {
330
+ try {
331
+ return statSync(path).isFile();
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ /** The `extends` value as a list: a string, or TypeScript 5.0+'s array form. */
338
+ function extendsList(value) {
339
+ if (typeof value === "string") return [value];
340
+ if (Array.isArray(value)) {
341
+ return value.filter((entry) => typeof entry === "string");
342
+ }
343
+ return [];
344
+ }
345
+
346
+ /**
347
+ * Resolves one `extends` specifier the way TypeScript does for the common
348
+ * cases: a relative path (`x`, `x.json`, `x/tsconfig.json`), or a bare
349
+ * package specifier looked up under every ancestor `node_modules`. A package
350
+ * `exports` map is out of scope -- such a specifier simply stays unresolved.
351
+ */
352
+ function resolveExtends(specifier, fromAbs) {
353
+ const candidates = (base) => [
354
+ base,
355
+ `${base}.json`,
356
+ join(base, "tsconfig.json"),
357
+ ];
358
+ if (specifier.startsWith(".") || isAbsolute(specifier)) {
359
+ return {
360
+ kind: "relative",
361
+ abs: candidates(resolve(dirname(fromAbs), specifier)).find(isFile),
362
+ };
363
+ }
364
+ let dir = dirname(fromAbs);
365
+ for (;;) {
366
+ const abs = candidates(join(dir, "node_modules", specifier)).find(isFile);
367
+ if (abs !== undefined) return { kind: "package", abs };
368
+ const parent = dirname(dir);
369
+ if (parent === dir) return { kind: "package", abs: undefined };
370
+ dir = parent;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Follows one tsconfig's `extends` chain and folds `compilerOptions` the way
376
+ * TypeScript does: array entries left to right, the file's own options last.
377
+ * `files` collects each project-owned file once across every chain (a
378
+ * `node_modules` base is read for its options but never graded as ours);
379
+ * `links` collects every `extends` edge. `complete` is false when a link could
380
+ * not be followed, so rules that judge effective options stand down.
381
+ */
382
+ function loadTsconfigChain(root, entry, files, links) {
383
+ let parsed = true;
384
+ let complete = true;
385
+ const members = [];
386
+ const visit = (abs, stack) => {
387
+ if (stack.includes(abs)) return {};
388
+ const rel = relative(root, abs).split("\\").join("/");
389
+ if (!members.includes(rel)) members.push(rel);
390
+ const read = readJsonc(abs);
391
+ const usable = read.ok && isRecord(read.value);
392
+ const own =
393
+ usable && isRecord(read.value.compilerOptions)
394
+ ? read.value.compilerOptions
395
+ : {};
396
+ if (!rel.startsWith("node_modules/") && !rel.startsWith("..")) {
397
+ if (!files.has(rel)) {
398
+ files.set(rel, {
399
+ rel,
400
+ error: usable
401
+ ? undefined
402
+ : read.ok
403
+ ? "top level is not an object"
404
+ : read.error,
405
+ options: own,
406
+ });
407
+ }
408
+ }
409
+ if (!usable) {
410
+ parsed = false;
411
+ complete = false;
412
+ return {};
413
+ }
414
+ let merged = {};
415
+ for (const specifier of extendsList(read.value.extends)) {
416
+ const target = resolveExtends(specifier, abs);
417
+ if (!links.some((l) => l.from === rel && l.specifier === specifier)) {
418
+ links.push({
419
+ from: rel,
420
+ specifier,
421
+ kind: target.kind,
422
+ resolved: target.abs !== undefined,
423
+ });
424
+ }
425
+ if (target.abs === undefined) {
426
+ complete = false;
427
+ continue;
428
+ }
429
+ merged = { ...merged, ...visit(target.abs, [...stack, abs]) };
430
+ }
431
+ return { ...merged, ...own };
432
+ };
433
+ const options = visit(join(root, entry), []);
434
+ return { entry, options, parsed, complete, members };
435
+ }
436
+
437
+ /** The `.json` file a `tsc -b|-p <file>` script names, if it names one. */
438
+ function scriptTsconfig(script) {
439
+ if (typeof script !== "string") return undefined;
440
+ const match =
441
+ /\btsc\b[^&|;\n]*?\s(?:-b|--build|-p|--project)\s+(?:--\S+\s+)*([^\s&|;]+\.json)/.exec(
442
+ script,
443
+ );
444
+ return match === null ? undefined : match[1].replace(/^\.\//, "");
445
+ }
446
+
447
+ /** Reads `verify-steps.mjs` as data: the `GROUPS` list and every `{ id, group, cmd }` literal. */
448
+ function scrapeGateSteps(text) {
449
+ const groupList = /\bGROUPS\s*=\s*\[([^\]]*)\]/.exec(text);
450
+ const groups =
451
+ groupList === null
452
+ ? []
453
+ : [...groupList[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
454
+ const steps = [];
455
+ for (const match of text.matchAll(
456
+ /\bid:\s*"([^"]+)"([^{}]*?)\bcmd:\s*\[([^\]]*)\]/g,
457
+ )) {
458
+ const group = /\bgroup:\s*"([^"]+)"/.exec(match[2]);
459
+ steps.push({
460
+ id: match[1],
461
+ group: group === null ? undefined : group[1],
462
+ cmd: [...match[3].matchAll(/"([^"]*)"/g)].map((m) => m[1]),
463
+ });
464
+ }
465
+ return { groups, steps };
466
+ }
467
+
468
+ /**
469
+ * Reads every `verify.mjs` invocation out of one YAML surface as data: the
470
+ * `--group`/`--step` each names. YAML is scraped, never parsed. An invocation
471
+ * naming neither (a matrix, or a bare full run) marks the surface `dynamic`.
472
+ */
473
+ function scrapeLaneInvocations(text) {
474
+ const groups = [];
475
+ const steps = [];
476
+ let dynamic = false;
477
+ let seen = false;
478
+ for (const raw of text.split("\n")) {
479
+ const line = raw.replace(/(^|\s)#.*$/, "");
480
+ const at = line.indexOf("verify.mjs");
481
+ if (at === -1) continue;
482
+ seen = true;
483
+ const rest = line.slice(at);
484
+ const group = /--group[ =]+([A-Za-z][\w-]*)/.exec(rest);
485
+ const step = /--step[ =]+([A-Za-z][\w-]*)/.exec(rest);
486
+ if (group !== null) groups.push(group[1]);
487
+ else if (step !== null) steps.push(step[1]);
488
+ else dynamic = true;
489
+ }
490
+ return { seen, groups, steps, dynamic };
491
+ }
492
+
493
+ function loadSnapshot(root) {
494
+ const entries = walkBounded(root, PROJECT_WALK_DEPTH);
495
+ const projectFiles = new Set(
496
+ entries.filter((e) => !e.isDirectory).map((e) => e.relPath),
497
+ );
498
+ const rootFiles = [...projectFiles].filter((p) => !p.includes("/"));
499
+
500
+ const packageRead = readJsonc(join(root, "package.json"));
501
+ const pkg =
502
+ packageRead.ok && isRecord(packageRead.value)
503
+ ? packageRead.value
504
+ : undefined;
505
+ const scripts = {};
506
+ if (pkg !== undefined && isRecord(pkg.scripts)) {
507
+ for (const [name, cmd] of Object.entries(pkg.scripts)) {
508
+ if (typeof cmd === "string") scripts[name] = cmd;
509
+ }
510
+ }
511
+
512
+ const tsconfigFiles = new Map();
513
+ const tsconfigLinks = [];
514
+ const chains = rootFiles
515
+ .filter((name) => /^tsconfig(\..+)?\.json$/.test(name))
516
+ .sort()
517
+ .map((name) => loadTsconfigChain(root, name, tsconfigFiles, tsconfigLinks));
518
+
519
+ const flatName = FLAT_ESLINT.find((name) => projectFiles.has(name));
520
+ const vitestName = VITEST_CONFIGS.find((name) => projectFiles.has(name));
521
+ const stepsPath = "bin/lib/verify-steps.mjs";
522
+ const packsPath = "bin/lib/verify-steps.packs.json";
523
+ const stepsText = projectFiles.has(stepsPath)
524
+ ? readSource(join(root, stepsPath))
525
+ : undefined;
526
+ const packsRead = projectFiles.has(packsPath)
527
+ ? readJsonc(join(root, packsPath))
528
+ : undefined;
529
+ const nodeVersionText = projectFiles.has(".node-version")
530
+ ? readSource(join(root, ".node-version"))
531
+ : undefined;
532
+
533
+ const lanes = [];
534
+ const laneSurfaces = [
535
+ [
536
+ rootFiles.find((name) => /^lefthook\.ya?ml$/.test(name)) ??
537
+ "lefthook.yml",
538
+ rootFiles.filter((name) => /^lefthook\.ya?ml$/.test(name)),
539
+ ],
540
+ [
541
+ ".github/workflows",
542
+ [...projectFiles].filter((p) =>
543
+ /^\.github\/workflows\/[^/]+\.ya?ml$/.test(p),
544
+ ),
545
+ ],
546
+ ];
547
+ for (const [surface, paths] of laneSurfaces) {
548
+ const text = paths.map((p) => readRaw(join(root, p)) ?? "").join("\n");
549
+ const lane = scrapeLaneInvocations(text);
550
+ // A surface that never runs verify.mjs is not judged: absence is no defect.
551
+ if (lane.seen) {
552
+ lanes.push({
553
+ surface,
554
+ groups: lane.groups,
555
+ steps: lane.steps,
556
+ dynamic: lane.dynamic,
557
+ });
558
+ }
559
+ }
560
+
561
+ return {
562
+ packageJson: pkg,
563
+ scripts,
564
+ nodeVersion: nodeVersionText?.trim(),
565
+ tsconfigFiles,
566
+ tsconfigLinks,
567
+ chains,
568
+ eslint: {
569
+ flatFile: flatName,
570
+ source:
571
+ flatName === undefined ? undefined : readSource(join(root, flatName)),
572
+ legacy: rootFiles.filter((name) => LEGACY_ESLINT.test(name)),
573
+ },
574
+ vitest: {
575
+ file: vitestName,
576
+ source:
577
+ vitestName === undefined
578
+ ? undefined
579
+ : readSource(join(root, vitestName)),
580
+ },
581
+ gates: {
582
+ stepsFile: stepsText === undefined ? undefined : stepsPath,
583
+ ...(stepsText === undefined
584
+ ? { groups: [], steps: [] }
585
+ : scrapeGateSteps(stepsText)),
586
+ packs:
587
+ packsRead === undefined
588
+ ? undefined
589
+ : {
590
+ path: packsPath,
591
+ error: packsRead.ok ? undefined : packsRead.error,
592
+ },
593
+ },
594
+ lanes,
595
+ projectFiles,
596
+ };
597
+ }
598
+
599
+ // --- helpers shared by rules -----------------------------------------------
600
+
601
+ /** The first number in a range: `^6.0.3` gives 6. `undefined` when there is none (`workspace:*`, `catalog:`). */
602
+ function majorOf(spec) {
603
+ const match = /(\d+)/.exec(spec ?? "");
604
+ return match === null ? undefined : Number(match[1]);
605
+ }
606
+
607
+ function depSpec(snapshot, name) {
608
+ const pkg = snapshot.packageJson;
609
+ if (pkg === undefined) return undefined;
610
+ for (const key of ["devDependencies", "dependencies"]) {
611
+ const section = pkg[key];
612
+ if (isRecord(section) && typeof section[name] === "string") {
613
+ return section[name];
614
+ }
615
+ }
616
+ return undefined;
617
+ }
618
+
619
+ const isUnpinned = (spec) => /^\s*(\*|x|latest|next)?\s*$/i.test(spec);
620
+
621
+ /**
622
+ * The chain a project's strict-flag judgement is made over: `tsconfig.json`,
623
+ * else the first chain no other chain extends (a leaf, not a base), else the
624
+ * first.
625
+ */
626
+ function primaryChain(snapshot) {
627
+ const named = snapshot.chains.find(
628
+ (chain) => chain.entry === "tsconfig.json",
629
+ );
630
+ if (named !== undefined) return named;
631
+ const leaf = snapshot.chains.find(
632
+ (chain) =>
633
+ !snapshot.chains.some(
634
+ (other) => other !== chain && other.members.includes(chain.entry),
635
+ ),
636
+ );
637
+ return leaf ?? snapshot.chains[0];
638
+ }
639
+
640
+ function ownFiles(snapshot) {
641
+ return [...snapshot.tsconfigFiles.values()].filter(
642
+ (file) => file.error === undefined,
643
+ );
644
+ }
645
+
646
+ /** The first year an ES target denotes; `Infinity` for `esnext`; `undefined` when unrecognised. */
647
+ function targetYear(target) {
648
+ const value = lower(target);
649
+ if (value === "esnext") return Infinity;
650
+ const match = /^es(\d+)$/.exec(value ?? "");
651
+ if (match === null) return undefined;
652
+ const n = Number(match[1]);
653
+ return n < 100 ? n + 2009 : n;
654
+ }
655
+
656
+ const NONE = { checked: 0, failures: [] };
657
+
658
+ // --- structural rules ------------------------------------------------------
659
+
660
+ const tsconfigParses = {
661
+ id: "tsconfig-parses",
662
+ level: "structural",
663
+ category: "tsconfig",
664
+ check: (s) => ({
665
+ checked: s.tsconfigFiles.size,
666
+ failures: [...s.tsconfigFiles.values()]
667
+ .filter((file) => file.error !== undefined)
668
+ .map((file) => ({
669
+ subject: file.rel,
670
+ message: `does not parse as JSONC: ${file.error}`,
671
+ })),
672
+ }),
673
+ };
674
+
675
+ const tsconfigExtendsResolves = {
676
+ id: "tsconfig-extends-resolves",
677
+ level: "structural",
678
+ category: "tsconfig",
679
+ check: (s) => {
680
+ // A bare specifier that resolves nowhere is not a failure: before
681
+ // `pnpm install` there is no node_modules to look in.
682
+ const judged = s.tsconfigLinks.filter(
683
+ (link) => link.kind === "relative" || link.resolved,
684
+ );
685
+ return {
686
+ checked: judged.length,
687
+ failures: judged
688
+ .filter((link) => !link.resolved)
689
+ .map((link) => ({
690
+ subject: link.from,
691
+ message: `extends "${link.specifier}", which resolves to no file`,
692
+ })),
693
+ };
694
+ },
695
+ };
696
+
697
+ const tsconfigEmitCoherence = {
698
+ id: "tsconfig-emit-coherence",
699
+ level: "structural",
700
+ category: "tsconfig",
701
+ check: (s) => {
702
+ const buildName = scriptTsconfig(s.scripts["build"]);
703
+ const build = s.chains.find((chain) => chain.entry === buildName);
704
+ if (build === undefined || !build.complete) return NONE;
705
+ const failures = [];
706
+ let checked = 2;
707
+ if (build.options["outDir"] === undefined) {
708
+ failures.push({
709
+ subject: build.entry,
710
+ message:
711
+ "is compiled by the `build` script but sets no outDir, so tsc emits .js next to the sources",
712
+ });
713
+ }
714
+ if (build.options["noEmit"] === true) {
715
+ failures.push({
716
+ subject: build.entry,
717
+ message:
718
+ "is compiled by the `build` script but sets noEmit: true, so `build` emits nothing",
719
+ });
720
+ }
721
+ const tooling = s.chains.find((chain) => chain.entry === "tsconfig.json");
722
+ if (tooling !== undefined && tooling.complete && tooling !== build) {
723
+ checked += 1;
724
+ if (tooling.options["noEmit"] !== true) {
725
+ failures.push({
726
+ subject: tooling.entry,
727
+ message: `sits beside ${build.entry} as the tooling project but does not set noEmit: true, so typecheck would emit`,
728
+ });
729
+ }
730
+ }
731
+ return { checked, failures };
732
+ },
733
+ };
734
+
735
+ const gateWiring = {
736
+ id: "gate-wiring",
737
+ level: "structural",
738
+ category: "gates",
739
+ check: (s) => {
740
+ const { stepsFile, groups, steps, packs } = s.gates;
741
+ const failures = [];
742
+ let checked = 0;
743
+
744
+ const scripts = s.packageJson === undefined ? undefined : s.scripts;
745
+ for (const step of steps) {
746
+ const [tool, first, second] = step.cmd;
747
+ if (tool === "pnpm" && scripts !== undefined && first !== undefined) {
748
+ const script = first === "run" ? second : first;
749
+ if (script !== undefined && !PNPM_BUILTINS.has(script)) {
750
+ checked += 1;
751
+ if (scripts[script] === undefined) {
752
+ failures.push({
753
+ subject: `${stepsFile} step "${step.id}"`,
754
+ message: `runs \`pnpm ${script}\`, but package.json has no "${script}" script`,
755
+ });
756
+ }
757
+ }
758
+ }
759
+ if (tool === "node" && first !== undefined && !first.startsWith("-")) {
760
+ checked += 1;
761
+ if (!s.projectFiles.has(first)) {
762
+ failures.push({
763
+ subject: `${stepsFile} step "${step.id}"`,
764
+ message: `runs \`node ${first}\`, which does not exist`,
765
+ });
766
+ }
767
+ }
768
+ if (groups.length > 0 && step.group !== undefined) {
769
+ checked += 1;
770
+ if (!groups.includes(step.group)) {
771
+ failures.push({
772
+ subject: `${stepsFile} step "${step.id}"`,
773
+ message: `names group "${step.group}", which is not in GROUPS (${groups.join(", ")})`,
774
+ });
775
+ }
776
+ }
777
+ }
778
+ if (steps.length > 0) {
779
+ for (const group of groups) {
780
+ checked += 1;
781
+ if (!steps.some((step) => step.group === group)) {
782
+ failures.push({
783
+ subject: `${stepsFile} group "${group}"`,
784
+ message:
785
+ "has no step, so its lefthook lane and CI job gate nothing",
786
+ });
787
+ }
788
+ }
789
+ }
790
+ if (packs !== undefined) {
791
+ checked += 1;
792
+ if (packs.error !== undefined) {
793
+ failures.push({
794
+ subject: packs.path,
795
+ message: `does not parse as JSON: ${packs.error}`,
796
+ });
797
+ }
798
+ }
799
+ return { checked, failures };
800
+ },
801
+ };
802
+
803
+ const gateLaneParity = {
804
+ id: "gate-lane-parity",
805
+ level: "structural",
806
+ category: "gates",
807
+ check: (s) => {
808
+ const { groups } = s.gates;
809
+ if (groups.length === 0) return NONE;
810
+ const failures = [];
811
+ let checked = 0;
812
+ for (const lane of s.lanes) {
813
+ // A lane that runs verify.mjs with no static group (a matrix, or a bare
814
+ // full run) cannot be judged by reading it.
815
+ if (lane.dynamic) continue;
816
+ for (const group of groups) {
817
+ checked += 1;
818
+ if (!lane.groups.includes(group)) {
819
+ failures.push({
820
+ subject: lane.surface,
821
+ message: `has no \`verify.mjs --group ${group}\` invocation, so that group's steps are never gated here`,
822
+ });
823
+ }
824
+ }
825
+ for (const named of new Set(lane.groups)) {
826
+ if (groups.includes(named)) continue;
827
+ checked += 1;
828
+ failures.push({
829
+ subject: lane.surface,
830
+ message: `runs \`verify.mjs --group ${named}\`, but ${named} is not in GROUPS (${groups.join(", ")})`,
831
+ });
832
+ }
833
+ for (const step of new Set(lane.steps)) {
834
+ checked += 1;
835
+ failures.push({
836
+ subject: lane.surface,
837
+ message: `runs \`verify.mjs --step ${step}\` by id; name a group instead so a new step joins this lane without editing it`,
838
+ });
839
+ }
840
+ }
841
+ return { checked, failures };
842
+ },
843
+ };
844
+
845
+ /** The lower and upper major an `engines.node` range implies; `undefined` for `||` ranges. */
846
+ function enginesBounds(range) {
847
+ if (range.includes("||")) return undefined;
848
+ const low = /(?:^|\s)(?:>=|\^|~|=)?\s*v?(\d+)/.exec(range);
849
+ const high = /<\s*(\d+)/.exec(range);
850
+ return {
851
+ min: low === null ? undefined : Number(low[1]),
852
+ max: high === null ? undefined : Number(high[1]),
853
+ };
854
+ }
855
+
856
+ const nodePinCoherence = {
857
+ id: "node-pin-coherence",
858
+ level: "structural",
859
+ category: "gates",
860
+ check: (s) => {
861
+ const pkg = s.packageJson;
862
+ const engines =
863
+ pkg !== undefined && isRecord(pkg.engines) ? pkg.engines.node : undefined;
864
+ if (s.nodeVersion === undefined || typeof engines !== "string") return NONE;
865
+ const pin = /^v?(\d+)/.exec(s.nodeVersion);
866
+ const bounds = enginesBounds(engines);
867
+ if (pin === null || bounds === undefined) return NONE;
868
+ const major = Number(pin[1]);
869
+ const failures = [];
870
+ if (bounds.min !== undefined && major < bounds.min) {
871
+ failures.push({
872
+ subject: ".node-version",
873
+ message: `pins Node ${major}, below the ${bounds.min} that package.json engines.node ("${engines}") requires`,
874
+ });
875
+ }
876
+ if (bounds.max !== undefined && major >= bounds.max) {
877
+ failures.push({
878
+ subject: ".node-version",
879
+ message: `pins Node ${major}, at or above the ${bounds.max} that package.json engines.node ("${engines}") excludes`,
880
+ });
881
+ }
882
+ return { checked: 1, failures };
883
+ },
884
+ };
885
+
886
+ // --- rubric rules ----------------------------------------------------------
887
+
888
+ const strictFlags = {
889
+ id: "strict-flags",
890
+ level: "rubric",
891
+ category: "tsconfig",
892
+ check: (s) => {
893
+ const chain = primaryChain(s);
894
+ if (chain === undefined || !chain.complete) return NONE;
895
+ const failures = [];
896
+ const expect = (flag, wanted) => {
897
+ const value = chain.options[flag];
898
+ if (value === wanted) return;
899
+ failures.push({
900
+ subject: chain.entry,
901
+ message:
902
+ value === undefined
903
+ ? `${flag} is not set (want ${wanted})`
904
+ : `${flag} is ${JSON.stringify(value)} (want ${wanted})`,
905
+ });
906
+ };
907
+ for (const flag of STRICT_FLAGS) expect(flag, true);
908
+ expect("allowUnreachableCode", false);
909
+ return { checked: STRICT_FLAGS.length + 1, failures };
910
+ },
911
+ };
912
+
913
+ const tsconfigOptionLifecycle = {
914
+ id: "tsconfig-option-lifecycle",
915
+ level: "rubric",
916
+ category: "tsconfig",
917
+ check: (s) => {
918
+ const major = majorOf(depSpec(s, "typescript"));
919
+ const files = ownFiles(s);
920
+ const failures = [];
921
+ for (const file of files) {
922
+ const hits = [];
923
+ if (major !== undefined) {
924
+ for (const row of LEGACY_OPTIONS) {
925
+ if (!row.matches(file.options[row.option])) continue;
926
+ if (major >= row.removedIn) hits.push(`${row.label} (removed)`);
927
+ else if (major >= row.deprecatedIn)
928
+ hits.push(`${row.label} (deprecated)`);
929
+ else if (major + 1 >= row.removedIn) {
930
+ hits.push(`${row.label} (removed in TypeScript ${row.removedIn})`);
931
+ }
932
+ }
933
+ }
934
+ if (file.options["ignoreDeprecations"] !== undefined) {
935
+ hits.push(
936
+ "ignoreDeprecations (a migration aid, not a long-term setting)",
937
+ );
938
+ }
939
+ if (hits.length > 0) {
940
+ failures.push({
941
+ subject: file.rel,
942
+ message: `sets ${hits.join(", ")}`,
943
+ });
944
+ }
945
+ }
946
+ return { checked: files.length, failures };
947
+ },
948
+ };
949
+
950
+ const moduleTargetModern = {
951
+ id: "module-target-modern",
952
+ level: "rubric",
953
+ category: "modules",
954
+ check: (s) => {
955
+ const chain = primaryChain(s);
956
+ if (chain === undefined || !chain.complete) return NONE;
957
+ const { module, moduleResolution, target } = chain.options;
958
+ const failures = [];
959
+ let checked = 0;
960
+ if (module !== undefined) {
961
+ checked += 1;
962
+ if (!MODERN_MODULES.has(lower(module))) {
963
+ failures.push({
964
+ subject: chain.entry,
965
+ message: `module is ${JSON.stringify(module)}; use nodenext (Node) or preserve (bundler)`,
966
+ });
967
+ }
968
+ }
969
+ if (moduleResolution !== undefined) {
970
+ checked += 1;
971
+ if (!MODERN_RESOLUTIONS.has(lower(moduleResolution))) {
972
+ failures.push({
973
+ subject: chain.entry,
974
+ message: `moduleResolution is ${JSON.stringify(moduleResolution)}; use nodenext or bundler`,
975
+ });
976
+ }
977
+ }
978
+ const year = targetYear(target);
979
+ if (year !== undefined) {
980
+ checked += 1;
981
+ if (year < MIN_TARGET_YEAR) {
982
+ failures.push({
983
+ subject: chain.entry,
984
+ message: `target is ${JSON.stringify(target)}; es2022 or later is the floor`,
985
+ });
986
+ }
987
+ }
988
+ return { checked, failures };
989
+ },
990
+ };
991
+
992
+ const eslintFlatConfig = {
993
+ id: "eslint-flat-config",
994
+ level: "rubric",
995
+ category: "eslint",
996
+ check: (s) => {
997
+ const { flatFile, legacy } = s.eslint;
998
+ if (flatFile !== undefined) return { checked: 1, failures: [] };
999
+ if (legacy.length === 0) return NONE;
1000
+ return {
1001
+ checked: 1,
1002
+ failures: legacy.map((name) => ({
1003
+ subject: name,
1004
+ message:
1005
+ "is a legacy eslintrc config; ESLint 10 reads only eslint.config.*",
1006
+ })),
1007
+ };
1008
+ },
1009
+ };
1010
+
1011
+ const eslintTypedLinting = {
1012
+ id: "eslint-typed-linting",
1013
+ level: "rubric",
1014
+ category: "eslint",
1015
+ check: (s) => {
1016
+ const { flatFile, source } = s.eslint;
1017
+ if (flatFile === undefined || source === undefined) return NONE;
1018
+ const direct =
1019
+ /\bfrom\s+["']typescript-eslint["']|require\(\s*["']typescript-eslint["']\s*\)/.test(
1020
+ source,
1021
+ );
1022
+ if (!direct) {
1023
+ // A preset composed through a shared config package cannot be judged
1024
+ // from this file alone -- say nothing rather than claim it is untyped.
1025
+ if (/["'](?:@[^/"']+\/)?eslint-config[^"']*["']/.test(source))
1026
+ return NONE;
1027
+ return {
1028
+ checked: 1,
1029
+ failures: [
1030
+ {
1031
+ subject: flatFile,
1032
+ message:
1033
+ "does not use typescript-eslint, so no TypeScript-aware rules run",
1034
+ },
1035
+ ],
1036
+ };
1037
+ }
1038
+ const failures = [];
1039
+ if (!/\b(?:recommended|strict)TypeChecked\b/.test(source)) {
1040
+ failures.push({
1041
+ subject: flatFile,
1042
+ message:
1043
+ "uses typescript-eslint without a type-checked preset (recommendedTypeChecked or strictTypeChecked)",
1044
+ });
1045
+ }
1046
+ if (!/\bprojectService\s*:/.test(source)) {
1047
+ failures.push({
1048
+ subject: flatFile,
1049
+ message:
1050
+ "does not set parserOptions.projectService, so type-aware rules cannot read type information",
1051
+ });
1052
+ }
1053
+ return { checked: 2, failures };
1054
+ },
1055
+ };
1056
+
1057
+ const eslintCoversEmittedCode = {
1058
+ id: "eslint-covers-emitted-code",
1059
+ level: "rubric",
1060
+ category: "eslint",
1061
+ check: (s) => {
1062
+ const { flatFile, source } = s.eslint;
1063
+ if (flatFile === undefined || source === undefined) return NONE;
1064
+ const failures = [];
1065
+ let checked = 1;
1066
+ const ignored = [...source.matchAll(/\bignores\s*:\s*\[([\s\S]*?)\]/g)]
1067
+ .flatMap((block) => [...block[1].matchAll(/["']([^"']+)["']/g)])
1068
+ .map((literal) => literal[1]);
1069
+ const dropsSrc = ignored.filter((glob) => /^(\*\*\/)?src(\/|$)/.test(glob));
1070
+ for (const glob of dropsSrc) {
1071
+ failures.push({
1072
+ subject: flatFile,
1073
+ message: `ignores "${glob}", so the project's own source is never linted`,
1074
+ });
1075
+ }
1076
+ const files = [...s.projectFiles];
1077
+ const scopes = [
1078
+ ["bin/", /\.(?:mjs|js)$/, /["']bin\/[^"']*["']/],
1079
+ [".claude/hooks/", /\.(?:mjs|js)$/, /["']\.claude\/hooks\/[^"']*["']/],
1080
+ ];
1081
+ for (const [prefix, extension, block] of scopes) {
1082
+ if (!files.some((p) => p.startsWith(prefix) && extension.test(p)))
1083
+ continue;
1084
+ checked += 1;
1085
+ if (!block.test(source)) {
1086
+ failures.push({
1087
+ subject: flatFile,
1088
+ message: `has no config block for ${prefix}**, whose scripts are code this project runs`,
1089
+ });
1090
+ }
1091
+ }
1092
+ return { checked, failures };
1093
+ },
1094
+ };
1095
+
1096
+ const coverageGate = {
1097
+ id: "coverage-gate",
1098
+ level: "rubric",
1099
+ category: "testing",
1100
+ check: (s) => {
1101
+ const { file, source } = s.vitest;
1102
+ if (file === undefined || source === undefined) return NONE;
1103
+ const failures = [];
1104
+ if (!/\bthresholds\s*:/.test(source)) {
1105
+ failures.push({
1106
+ subject: file,
1107
+ message:
1108
+ "sets no coverage thresholds, so coverage can fall without failing anything",
1109
+ });
1110
+ }
1111
+ if (!/\bperFile\s*:\s*true\b/.test(source)) {
1112
+ failures.push({
1113
+ subject: file,
1114
+ message:
1115
+ "does not set coverage.thresholds.perFile: true, so one well-covered file hides an untested one",
1116
+ });
1117
+ }
1118
+ return { checked: 2, failures };
1119
+ },
1120
+ };
1121
+
1122
+ const toolchainPinShape = {
1123
+ id: "toolchain-pin-shape",
1124
+ level: "rubric",
1125
+ category: "deps",
1126
+ check: (s) => {
1127
+ const pkg = s.packageJson;
1128
+ if (pkg === undefined) return NONE;
1129
+ const failures = [];
1130
+ let checked = 0;
1131
+ const packages = [...PIN_PACKAGES];
1132
+ if (s.vitest.file !== undefined || depSpec(s, "vitest") !== undefined) {
1133
+ packages.push("vitest");
1134
+ }
1135
+ for (const name of packages) {
1136
+ checked += 1;
1137
+ const spec = depSpec(s, name);
1138
+ if (spec === undefined) {
1139
+ failures.push({
1140
+ subject: name,
1141
+ message: "is not declared in package.json",
1142
+ });
1143
+ } else if (isUnpinned(spec)) {
1144
+ failures.push({
1145
+ subject: name,
1146
+ message: `is "${spec}", not a real version range, so installs are not reproducible`,
1147
+ });
1148
+ }
1149
+ }
1150
+ checked += 1;
1151
+ if (typeof pkg.packageManager !== "string") {
1152
+ failures.push({
1153
+ subject: "package.json",
1154
+ message:
1155
+ "declares no packageManager, so the package-manager version is unpinned",
1156
+ });
1157
+ }
1158
+ const types = majorOf(depSpec(s, "@types/node"));
1159
+ const pin = /^v?(\d+)/.exec(s.nodeVersion ?? "");
1160
+ if (types !== undefined && pin !== null) {
1161
+ checked += 1;
1162
+ if (types !== Number(pin[1])) {
1163
+ failures.push({
1164
+ subject: "@types/node",
1165
+ message: `is major ${types}, but .node-version pins Node ${pin[1]}, so types describe a different runtime`,
1166
+ });
1167
+ }
1168
+ }
1169
+ return { checked, failures };
1170
+ },
1171
+ };
1172
+
1173
+ /**
1174
+ * Every rule, structural first. Order is the order findings are reported in.
1175
+ * @public Exported for the parity test that compares this file with its TypeScript twin in
1176
+ * m3l-groundwork; nothing else in a bootstrapped project imports it.
1177
+ */
1178
+ export const RULES = [
1179
+ tsconfigParses,
1180
+ tsconfigExtendsResolves,
1181
+ tsconfigEmitCoherence,
1182
+ gateWiring,
1183
+ gateLaneParity,
1184
+ nodePinCoherence,
1185
+ strictFlags,
1186
+ tsconfigOptionLifecycle,
1187
+ moduleTargetModern,
1188
+ eslintFlatConfig,
1189
+ eslintTypedLinting,
1190
+ eslintCoversEmittedCode,
1191
+ coverageGate,
1192
+ toolchainPinShape,
1193
+ ];
1194
+
1195
+ // --- grading and reporting -------------------------------------------------
1196
+
1197
+ /**
1198
+ * Prints a grade through a `bin/lib/report.mjs` reporter. Kept here rather
1199
+ * than in each gate script so the repo's own wrapper and the emitted gate
1200
+ * cannot report the same grade differently.
1201
+ */
1202
+ export function reportGrade(grade, reporter) {
1203
+ for (const finding of grade.findings) {
1204
+ const line = `[${finding.ruleId}] ${finding.subject} -- ${finding.message}`;
1205
+ if (finding.level === "structural") {
1206
+ reporter.fail(line);
1207
+ } else {
1208
+ reporter.warn(line);
1209
+ }
1210
+ }
1211
+ const { checked, failed } = grade.structural;
1212
+ if (failed === 0) {
1213
+ reporter.ok(`toolchain wiring: ${checked} structural checks passed`);
1214
+ }
1215
+ const rubricChecked = Object.values(grade.rubric).reduce(
1216
+ (sum, tally) => sum + tally.checked,
1217
+ 0,
1218
+ );
1219
+ reporter.ok(
1220
+ `toolchain rubric: ${(grade.rubricScore * 100).toFixed(0)}% over ${rubricChecked} checks (warnings never fail the gate)`,
1221
+ );
1222
+ }
1223
+
1224
+ /** Runs every rule over the project rooted at `rootDir` and tallies the result. */
1225
+ export function gradeToolchain(rootDir) {
1226
+ const snapshot = loadSnapshot(rootDir);
1227
+ const findings = [];
1228
+ const structural = { checked: 0, failed: 0 };
1229
+ const rubric = Object.fromEntries(
1230
+ CATEGORIES.map((category) => [category, { checked: 0, failed: 0 }]),
1231
+ );
1232
+
1233
+ for (const rule of RULES) {
1234
+ const result = rule.check(snapshot);
1235
+ const tally =
1236
+ rule.level === "structural" ? structural : rubric[rule.category];
1237
+ tally.checked += result.checked;
1238
+ tally.failed += result.failures.length;
1239
+ for (const failure of result.failures) {
1240
+ findings.push({
1241
+ ruleId: rule.id,
1242
+ level: rule.level,
1243
+ category: rule.category,
1244
+ subject: failure.subject,
1245
+ message: failure.message,
1246
+ });
1247
+ }
1248
+ }
1249
+
1250
+ const rubricChecked = Object.values(rubric).reduce(
1251
+ (sum, tally) => sum + tally.checked,
1252
+ 0,
1253
+ );
1254
+ const rubricFailed = Object.values(rubric).reduce(
1255
+ (sum, tally) => sum + tally.failed,
1256
+ 0,
1257
+ );
1258
+ return {
1259
+ findings,
1260
+ structural,
1261
+ rubric,
1262
+ rubricScore: rubricChecked === 0 ? 1 : 1 - rubricFailed / rubricChecked,
1263
+ };
1264
+ }