@starci/eslint-canon-fe 6.0.2 → 7.1.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 (117) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +3 -5
  3. package/client-boundary.mjs +2 -5
  4. package/comments.mjs +38 -15
  5. package/docs/brand-values.md +2 -2
  6. package/docs/client-boundary.md +10 -10
  7. package/docs/env-owner.md +4 -4
  8. package/docs/formatting.md +2 -2
  9. package/docs/grammar-boundary.md +2 -2
  10. package/docs/hooks-folder.md +2 -2
  11. package/docs/hygiene.md +8 -8
  12. package/docs/lint-escape-hatch.md +2 -2
  13. package/docs/lists.md +6 -6
  14. package/docs/native-controls.md +6 -6
  15. package/docs/next-conventions.md +20 -20
  16. package/docs/size-and-state-budget.md +6 -6
  17. package/docs/size-growth.md +2 -2
  18. package/docs/status-colors.md +2 -2
  19. package/docs/translation.md +2 -2
  20. package/docs/transport.md +18 -18
  21. package/docs/type-safety.md +8 -8
  22. package/env-owner.mjs +2 -3
  23. package/file-layout.mjs +3 -43
  24. package/formatting.mjs +2 -2
  25. package/grammar-boundary.mjs +1 -2
  26. package/hooks-folder.mjs +6 -7
  27. package/index.mjs +5 -27
  28. package/lib/config.mjs +6 -22
  29. package/lib/scope.mjs +3 -12
  30. package/lib/why.mjs +3 -68
  31. package/lint-escape-hatch.mjs +3 -3
  32. package/lists.mjs +0 -4
  33. package/loading.mjs +2 -2
  34. package/native-controls.mjs +0 -4
  35. package/next-conventions.mjs +3 -7
  36. package/package.json +1 -1
  37. package/project-graph.mjs +23 -0
  38. package/props-and-slots.mjs +4 -5
  39. package/runtime/knowledge/hfs/slots.yaml +145 -107
  40. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  41. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  42. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  43. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  44. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  45. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  46. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  47. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  48. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  49. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  50. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  51. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  52. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  53. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  54. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  55. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  56. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  57. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  58. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  59. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  60. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  61. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  62. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  63. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  64. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  65. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  66. package/runtime/scripts/checks/architecture/index.mjs +321 -0
  67. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  68. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  69. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  70. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  71. package/runtime/scripts/checks/architecture/owners.mjs +95 -0
  72. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  73. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  74. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  75. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  76. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  77. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  78. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  79. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  80. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  81. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  82. package/runtime/scripts/checks/architecture/surface.mjs +91 -0
  83. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  84. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  85. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  86. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  87. package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
  88. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  89. package/runtime/scripts/checks/common.mjs +44 -0
  90. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  91. package/runtime/scripts/lib/hfs-allows.mjs +15 -0
  92. package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
  93. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  94. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  95. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +13 -6
  98. package/runtime/scripts/lib/json.mjs +22 -0
  99. package/runtime/scripts/lib/language.mjs +107 -0
  100. package/runtime/scripts/lib/project-graph.mjs +0 -0
  101. package/runtime/scripts/lib/project-rule.mjs +39 -0
  102. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  103. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  104. package/runtime/scripts/lib/stack-services.mjs +217 -0
  105. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  106. package/served-locale.mjs +2 -2
  107. package/shape-slot.mjs +2 -3
  108. package/size-and-state-budget.mjs +1 -4
  109. package/size-growth.mjs +1 -2
  110. package/status-colors.mjs +0 -2
  111. package/tokens.mjs +1 -1
  112. package/translation.mjs +1 -29
  113. package/transport.mjs +13 -17
  114. package/docs/e2e-shape.md +0 -161
  115. package/docs/spec-quality.md +0 -147
  116. package/e2e-shape.mjs +0 -345
  117. package/spec-quality.mjs +0 -318
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { machineKit } from './machine-ast.mjs';
4
+ import { constructorDependencies } from './constructor-deps.mjs';
5
+ import { treeOf } from './required-files.mjs';
6
+
7
+ /**
8
+ * R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
9
+ * subject with `Test.createTestingModule({ providers: [...] })`, and that providers array EQUALS the constructor
10
+ * dependencies of the service under test: the service class first, then one provider for each constructor parameter.
11
+ *
12
+ * The service under test is the sibling `<name>.service.ts` of every `<name>.service.spec.ts` under src/ or apps/ (a spec is
13
+ * outside the production program, so it is read from disk). A parameter decorated with a custom `Inject<Thing>()` provides
14
+ * the token that decorator's `injector<T>(TOKEN)` names; an undecorated parameter of a class type provides that class (see
15
+ * constructor-deps.mjs). Providers are matched by the exported name of the imported binding (`import { CACHE as C }` is
16
+ * CACHE), never by text. A provider is a class, or `{ provide: TOKEN, useValue: double }` and nothing else.
17
+ *
18
+ * Findings: an extra provider, a missing provider, a service that is not the first provider, a dependency whose token cannot
19
+ * be resolved, a spec with no createTestingModule providers array, and a provider that is neither a class nor
20
+ * `{ provide, useValue }`. Which double a `useValue` is, and whether other specs exist at all, are other rules' business.
21
+ */
22
+ export const UNIT_SPEC_PROVIDERS_RULE_IDS = ['BE_SPEC_QUALITY'];
23
+
24
+ const RULE = 'BE_SPEC_QUALITY';
25
+ const SERVICE_SPEC = /\.service\.spec\.ts$/u;
26
+ const SPEC_ROOT = /^(?:src|apps)\//u;
27
+
28
+ /** local name -> {name, module} for every named import of a source file. */
29
+ function importsOf(ts, sourceFile) {
30
+ const imports = new Map();
31
+ for (const statement of sourceFile.statements) {
32
+ const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : null;
33
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
34
+ for (const element of bindings.elements) imports.set(element.name.text, { name: (element.propertyName ?? element.name).text, module: statement.moduleSpecifier.text });
35
+ }
36
+ return imports;
37
+ }
38
+
39
+ /** The `Test.createTestingModule(...)` calls of a spec. */
40
+ function testingModules(ts, sourceFile) {
41
+ const calls = [];
42
+ const visit = node => {
43
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'createTestingModule') calls.push(node);
44
+ ts.forEachChild(node, visit);
45
+ };
46
+ visit(sourceFile);
47
+ return calls;
48
+ }
49
+
50
+ const literalKey = (ts, property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
51
+
52
+ export function checkUnitSpecProviders(input) {
53
+ const { config, graph } = input;
54
+ const kit = machineKit(input);
55
+ const { ts } = kit;
56
+ const violations = [];
57
+ let specs = 0;
58
+ const tree = treeOf(config.root);
59
+ for (const rel of [...tree.files].filter(file => SPEC_ROOT.test(file) && SERVICE_SPEC.test(file)).sort()) {
60
+ const abs = path.join(config.root, ...rel.split('/'));
61
+ if (!fs.existsSync(abs)) continue;
62
+ specs += 1;
63
+ const sourceFile = ts.createSourceFile(abs, fs.readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true);
64
+ const report = (node, message) => violations.push({ ruleId: RULE, path: rel, ...kit.at(rel, sourceFile, node), message });
65
+ const imports = importsOf(ts, sourceFile);
66
+ const nameOf = identifier => imports.get(identifier.text)?.name ?? identifier.text;
67
+
68
+ const serviceRel = rel.replace(/\.spec\.ts$/u, '.ts');
69
+ const serviceFile = graph.files.get(serviceRel);
70
+ if (!serviceFile) { report(sourceFile, `${rel} has no ${path.posix.basename(serviceRel)} beside it; a service spec sits next to the service it tests.`); continue; }
71
+
72
+ const modules = testingModules(ts, sourceFile);
73
+ if (!modules.length) { report(sourceFile, `${rel} never calls Test.createTestingModule({ providers: [...] }); build the service from a testing module holding exactly its constructor dependencies.`); continue; }
74
+
75
+ for (const call of modules) {
76
+ const options = call.arguments[0];
77
+ const property = options && ts.isObjectLiteralExpression(options) ? options.properties.find(item => literalKey(ts, item) === 'providers') : null;
78
+ const list = property && ts.isPropertyAssignment(property) && ts.isArrayLiteralExpression(property.initializer) ? property.initializer : null;
79
+ if (!list) { report(call, `${rel}: Test.createTestingModule has no literal \`providers\` array; list the service and each constructor dependency there.`); continue; }
80
+
81
+ const provided = []; // [{name, node}]
82
+ let readable = true;
83
+ for (const element of list.elements) {
84
+ if (ts.isIdentifier(element)) { provided.push({ name: nameOf(element), node: element, isClass: true }); continue; }
85
+ if (ts.isObjectLiteralExpression(element)) {
86
+ const keys = element.properties.map(item => literalKey(ts, item));
87
+ const provide = element.properties.find(item => literalKey(ts, item) === 'provide');
88
+ const value = provide && ts.isPropertyAssignment(provide) ? provide.initializer : null;
89
+ if (keys.length === 2 && keys.includes('useValue') && value && ts.isIdentifier(value)) { provided.push({ name: nameOf(value), node: element, isClass: false }); continue; }
90
+ }
91
+ readable = false;
92
+ report(element, `${rel}: a provider is a class or \`{ provide: TOKEN, useValue: double }\` and nothing else (no useClass, useFactory, useExisting, spread or computed token).`);
93
+ }
94
+ if (!readable) continue;
95
+
96
+ const first = list.elements[0];
97
+ const serviceName = first && ts.isIdentifier(first) ? imports.get(first.text) : null;
98
+ const target = serviceName ? path.posix.normalize(path.posix.join(path.posix.dirname(rel), serviceName.module)).replace(/\.[cm]?[jt]s$/u, '') : null;
99
+ const serviceClass = serviceName && target === serviceRel.replace(/\.ts$/u, '')
100
+ ? serviceFile.sourceFile.statements.find(statement => ts.isClassDeclaration(statement) && statement.name?.text === serviceName.name) : null;
101
+ if (!serviceClass) { report(first ?? list, `${rel}: the first provider is the service under test, imported from ./${path.posix.basename(serviceRel, '.ts')}.`); continue; }
102
+
103
+ const dependencies = constructorDependencies(kit, serviceFile, serviceClass);
104
+ const expected = [];
105
+ for (const dependency of dependencies) {
106
+ if (dependency.kind === 'unresolved') {
107
+ report(list, `${rel}: a constructor dependency of ${serviceName.name} cannot be resolved to a token (${dependency.reason}), so its provider cannot be checked; inject it through an exported Inject<Thing>() decorator or a class type.`);
108
+ continue;
109
+ }
110
+ expected.push(dependency.name);
111
+ }
112
+ const remaining = [...expected];
113
+ for (const item of provided.slice(1)) {
114
+ const index = remaining.indexOf(item.name);
115
+ if (index === -1) report(item.node, `${rel}: ${item.name} is provided but is not a constructor dependency of ${serviceName.name}; a unit spec provides exactly what the constructor needs.`);
116
+ else remaining.splice(index, 1);
117
+ }
118
+ for (const name of remaining) report(list, `${rel}: ${name} is a constructor dependency of ${serviceName.name} but is not provided; add \`{ provide: ${name}, useValue: double }\` (or the class itself) to the providers.`);
119
+ }
120
+ }
121
+ return { violations, coverage: { status: 'checked', specs, files: graph.files.size } };
122
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+
5
+ /** Whether `target` is `root` itself or inside it (filesystem paths, either separator). */
6
+ export function isInside(root, target) {
7
+ const relative = path.relative(root, target);
8
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
9
+ }
10
+
11
+ /** True when this module is the process entry point (`node file.mjs`, not an import). */
12
+ /** The real path of a file, or its resolved path when it cannot be read (a missing file is never the entry module). */
13
+ const realPathOf = (file) => { try { return fs.realpathSync.native(path.resolve(file)); } catch { return path.resolve(file); } };
14
+ /**
15
+ * True when the module at `metaUrl` is the process entry point. Both sides are compared as REAL paths: Node gives a module
16
+ * its symlink-resolved URL, while argv[1] keeps the path it was started with, so an entry reached through a node_modules
17
+ * junction or symlink (a lane worktree, an npx shim) would otherwise look like an import and silently do nothing.
18
+ */
19
+ export const isMain = (metaUrl, argv = process.argv) => Boolean(argv[1]) && realPathOf(argv[1]) === realPathOf(fileURLToPath(metaUrl));
20
+
21
+ /**
22
+ * Every entry under `dir` that is not a directory, depth-first; `filter` sees the entry name and full
23
+ * path. `sorted` sorts each directory's entries by name (default: readdir order). `exclude`
24
+ * applies to files and directories before descent; `maxDepth` counts from the starting dir.
25
+ */
26
+ export function walkFiles(dir, {filter = () => true, sorted = false, exclude = () => false,
27
+ maxDepth = Infinity, ignoreReadErrors = false} = {}) {
28
+ const out = [];
29
+ const visit = (current, depth) => {
30
+ if (depth > maxDepth) return;
31
+ let entries;
32
+ try { entries = fs.readdirSync(current, {withFileTypes: true}); }
33
+ catch (error) { if (ignoreReadErrors) return; throw error; }
34
+ if (sorted) entries = entries.sort((a, b) => a.name.localeCompare(b.name));
35
+ for (const entry of entries) {
36
+ const full = path.join(current, entry.name);
37
+ if (exclude(entry.name, full, entry)) continue;
38
+ if (entry.isDirectory()) visit(full, depth + 1);
39
+ else if (filter(entry.name, full)) out.push(full);
40
+ }
41
+ };
42
+ visit(dir, 0);
43
+ return out;
44
+ }
@@ -0,0 +1,82 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ // One TypeScript program per (compiler, root names, compiler options, project references) inside a program run, and
6
+ // one value per (kind, compiler, input) for what callers derive from those programs (the architecture context).
7
+ // check-scoped-lint opens one run around its architecture check and its script checkers and releases it before
8
+ // ESLint and the base measurement, so nothing built here outlives the run that built it. Outside a run every call
9
+ // builds afresh.
10
+ const runs = new AsyncLocalStorage();
11
+
12
+ /** A program run: run(fn) shares what is built inside fn; release() drops it. */
13
+ export function typeScriptProgramRun() {
14
+ const values = new Map();
15
+ return {
16
+ run: fn => runs.run(values, fn),
17
+ release: () => values.clear(),
18
+ };
19
+ }
20
+
21
+ function keyOf(value) {
22
+ if (value === undefined) return 'u';
23
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
24
+ if (Array.isArray(value)) {
25
+ const items = value.map(keyOf);
26
+ return items.includes(null) ? null : `[${items.join(',')}]`;
27
+ }
28
+ if (typeof value !== 'object' || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) return null;
29
+ const entries = [];
30
+ for (const name of Object.keys(value).sort()) {
31
+ const item = keyOf(value[name]);
32
+ if (item === null) return null;
33
+ entries.push(`${JSON.stringify(name)}:${item}`);
34
+ }
35
+ return `{${entries.join(',')}}`;
36
+ }
37
+
38
+ /**
39
+ * build(), shared inside a program run by every caller that asks for the same kind, compiler and input. An input that
40
+ * is not plain data (a function, a class instance) is never shared.
41
+ */
42
+ export function sharedInProgramRun(kind, ts, input, build) {
43
+ const values = runs.getStore();
44
+ const key = values ? keyOf(input) : null;
45
+ if (key === null) return build();
46
+ let byKey = values.get(ts);
47
+ if (!byKey) values.set(ts, byKey = new Map());
48
+ const id = `${kind}\0${key}`;
49
+ if (!byKey.has(id)) byKey.set(id, build());
50
+ return byKey.get(id);
51
+ }
52
+
53
+ /** ts.createProgram, shared inside a program run. */
54
+ export function createTypeScriptProgram(ts, { rootNames, options, projectReferences }) {
55
+ return sharedInProgramRun('program', ts, { rootNames, options, projectReferences },
56
+ () => ts.createProgram({ rootNames, options, projectReferences }));
57
+ }
58
+
59
+ /** Read and expand a tsconfig with the caller's TypeScript compiler and optional tracked file reader. */
60
+ export function readTypeScriptProject(ts, configFile, readFile = ts.sys.readFile) {
61
+ const read = ts.readConfigFile(configFile, readFile);
62
+ if (read.error) return { read, parsed: null };
63
+ const host = readFile === ts.sys.readFile ? ts.sys : { ...ts.sys, readFile };
64
+ return { read, parsed: ts.parseJsonConfigFileContent(read.config, host, path.dirname(configFile), undefined, configFile) };
65
+ }
66
+
67
+ /** Resolve a project reference with the compiler's API, including older compiler fallback. */
68
+ export function typeScriptProjectReferencePath(ts, reference) {
69
+ if (typeof ts.resolveProjectReferencePath === 'function') return ts.resolveProjectReferencePath(reference);
70
+ return path.extname(reference.path) ? reference.path : path.join(reference.path, 'tsconfig.json');
71
+ }
72
+
73
+ /** Resolve an import using the selected project's compiler options. */
74
+ export function resolveTypeScriptModule(ts, specifier, from, options, host = ts.sys) {
75
+ return ts.resolveModuleName(specifier, from, options, host).resolvedModule?.resolvedFileName;
76
+ }
77
+
78
+ /** Configuration imports prefer Node's package resolution, then the selected TypeScript project. */
79
+ export function resolveNodeOrTypeScriptModule(ts, specifier, from, options, host = ts.sys) {
80
+ try { return createRequire(from).resolve(specifier); }
81
+ catch { return resolveTypeScriptModule(ts, specifier, from, options, host); }
82
+ }
@@ -26,6 +26,21 @@ export function relativeToRoot(file, root) {
26
26
  return root && file.startsWith(`${root}/`) ? file.slice(root.length + 1) : path.posix.basename(file);
27
27
  }
28
28
 
29
+ /**
30
+ * Whether the slot that owns `file` names it in `requires` or `allows` (and does not forbid it); null when no enabled slot
31
+ * owns it. Unlike allowsFile it judges a slot that lists only `requires` too, so a folder closed by its slot (an app's
32
+ * src/) is read from the slot and never from a check's own list.
33
+ */
34
+ export function slotAdmitsFile(resolver, file) {
35
+ const classified = resolver.classifyPath(file);
36
+ if (classified.status === 'no-slot' || classified.status === 'ambiguous' || !classified.slot) return null;
37
+ const slot = resolver.slot(classified.slot);
38
+ const relative = relativeToRoot(classified.path, classified.root);
39
+ const hit = matchersOf([...(slot.requires ?? []), ...(slot.allows ?? [])], classified.bindings).find(matcher => matcher.expression.test(relative));
40
+ const forbidden = matchersOf(slot.forbids, classified.bindings).find(matcher => matcher.expression.test(relative) || matcher.expression.test(`${relative}/`));
41
+ return Boolean(hit) && !forbidden;
42
+ }
43
+
29
44
  /**
30
45
  * {slot, root, relative, allowed, entry?, forbiddenBy?} for a repository-relative file, or null when no enabled slot owns
31
46
  * it or the slot names no `allows`. `allowed` is true when the relative path matches a `requires` or `allows` entry.
@@ -0,0 +1,84 @@
1
+ // hfs-path-findings.mjs - the per-path judgements of the slot manifest: what a tracked path is. One function, two surfaces: `hfs check`
2
+ // (checkRepo) runs it over the tracked tree and keeps the findings that have no TypeScript file to sit on; the lint canon's project graph
3
+ // (scripts/lib/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
4
+ // HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
5
+ // BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
6
+ import path from 'node:path';
7
+ import { allowsFile } from './hfs-allows.mjs';
8
+ import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
9
+ import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
10
+ import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
11
+
12
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const SOURCE_ROOT = /^(?:src|apps)\//;
14
+ const FREE_NAMES = new Set(['index.ts', 'main.ts']);
15
+ const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
16
+
17
+ /**
18
+ * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
19
+ * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
20
+ * as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
21
+ * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
22
+ */
23
+ function sourceFormFindings({ files, resolver }) {
24
+ const { suffixes, bannedSuffixes } = resolver.ruleParams();
25
+ // A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
26
+ // anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
27
+ const boundSuffixes = new Map();
28
+ for (const slot of resolver.slots()) {
29
+ const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
30
+ if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
31
+ }
32
+ const findings = [];
33
+ for (const file of files) {
34
+ if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
35
+ const c = resolver.classifyPath(file);
36
+ if (c.status !== 'owned' || c.tracking === 'ignored') continue;
37
+ const base = path.posix.basename(file);
38
+ if (FREE_NAMES.has(base)) continue;
39
+ // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
40
+ const slot = resolver.slot(c.slot);
41
+ if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
42
+ // A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
43
+ const admitted = allowsFile(resolver, file);
44
+ if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
45
+ if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
46
+ const parts = base.slice(0, -'.ts'.length).split('.');
47
+ const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
48
+ if (banned) {
49
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})` });
50
+ } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
51
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
52
+ } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
53
+ findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
54
+ }
55
+ }
56
+ return findings;
57
+ }
58
+
59
+ /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
60
+ export function pathFindings({ files, resolver, profile }) {
61
+ const findings = [];
62
+ for (const file of files) {
63
+ if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
64
+ const c = resolver.classifyPath(file);
65
+ if (c.status === 'no-slot') {
66
+ findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
67
+ } else if (c.status === 'ambiguous') {
68
+ findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
69
+ } else if (c.status === 'not-enabled') {
70
+ findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
71
+ } else if (c.status === 'forbidden') {
72
+ const slot = resolver.slot(c.slot);
73
+ if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
74
+ const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
75
+ findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
76
+ } else if (c.tracking === 'ignored') {
77
+ findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
78
+ }
79
+ }
80
+
81
+ if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
82
+ // The lint canon's project graph serves these findings on a TypeScript file; the origin keeps them apart from the machine's.
83
+ return findings.map((finding) => ({ ...finding, origin: 'repo' }));
84
+ }
@@ -0,0 +1,47 @@
1
+ // fe-no-tests.mjs - FE_NO_TESTS (R97): a front-end repository has no tests by standard (owner 2026-09-30). There is no exception: a spec
2
+ // anywhere in a front-end repository is a finding, `scripts/` included.
3
+ // - a tracked test file: `*.spec.*`, `*.test.*`, `*-spec.*` (also `*.e2e-spec.*`);
4
+ // - a tracked test directory: `e2e/`, `__tests__/`, `__mocks__/` or `test-support/`;
5
+ // - a tracked test-tool file: `vitest.*`, `jest.*`, `playwright.*` (config, setup, workspace), `cypress.config.*`, `tsconfig.e2e.json`, `e2e.yml`;
6
+ // - a test script in a package.json: a script named `test`, `test:*` (or with a pre/post prefix), or one that runs a test runner;
7
+ // - a test dependency in a package.json: a test runner, a test environment, testing-library or an axe package.
8
+ // The check reads the tracked tree and the manifests only. One finding per path (a manifest: one per script or dependency), and it is
9
+ // the only finding of a test file: the slot check leaves these paths to it (isFeTestPath).
10
+ import { found, readJson } from './read.mjs';
11
+
12
+ export const FE_NO_TESTS = 'FE_NO_TESTS';
13
+ const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
14
+ const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
15
+ const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
+ const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
17
+ const TEST_RUNNER_COMMAND = /(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b/;
18
+ const TEST_DEPENDENCY = /^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$/;
19
+ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
20
+
21
+ /** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
22
+ export const isFeTestPath = (file) => SPEC_FILE.test(file) || TEST_DIRECTORY.test(file) || TEST_TOOL_FILE.test(file);
23
+
24
+ /** The findings of R97 over the tracked paths `files` of a front-end repository at `repoRoot`. */
25
+ export function feNoTestsFindings({ repoRoot, files }) {
26
+ const findings = [];
27
+ for (const file of files) {
28
+ if (isFeTestPath(file)) {
29
+ const what = SPEC_FILE.test(file) ? 'a test file' : TEST_DIRECTORY.test(file) ? 'inside a test directory' : 'test tooling';
30
+ findings.push(found(FE_NO_TESTS, file, `${file} is ${what}; a front-end repository has no tests, no test tooling and no exception: delete it`));
31
+ continue;
32
+ }
33
+ if (file !== 'package.json' && !file.endsWith('/package.json')) continue;
34
+ if (file.includes('node_modules/')) continue;
35
+ const pkg = readJson(repoRoot, file);
36
+ if (!pkg || typeof pkg !== 'object') continue;
37
+ for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
38
+ if (TEST_SCRIPT_NAME.test(name) || TEST_RUNNER_COMMAND.test(String(command))) findings.push(found(FE_NO_TESTS, file, `${file} script ${name} is a test script (${String(command).slice(0, 80)}); a front-end repository has no tests: delete the script`));
39
+ }
40
+ for (const section of DEPENDENCY_SECTIONS) {
41
+ for (const name of Object.keys(pkg[section] ?? {})) {
42
+ if (TEST_DEPENDENCY.test(name)) findings.push(found(FE_NO_TESTS, file, `${file} ${section} names ${name}, a test dependency; a front-end repository has no tests: delete the dependency`));
43
+ }
44
+ }
45
+ }
46
+ return findings;
47
+ }
@@ -0,0 +1,28 @@
1
+ // read.mjs - what the tree checks of hfs-check.mjs share: one bounded text read and the shape of a finding.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /** Files above this size are not read by a tree check (a tracked binary or a generated dump is no source of these laws). */
6
+ export const MAX_READ_BYTES = 1024 * 1024;
7
+
8
+ /** The text of `rel` under `repoRoot`, or null when it is absent, unreadable, over MAX_READ_BYTES or binary. */
9
+ export function readText(repoRoot, rel) {
10
+ try {
11
+ const target = path.join(repoRoot, rel);
12
+ if (fs.statSync(target).size > MAX_READ_BYTES) return null;
13
+ const text = fs.readFileSync(target, 'utf8');
14
+ return text.includes('\0') ? null : text;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /** The JSON of `rel`, or null when it is absent or not JSON. */
21
+ export function readJson(repoRoot, rel) {
22
+ const text = readText(repoRoot, rel);
23
+ if (text === null) return null;
24
+ try { return JSON.parse(text); } catch { return null; }
25
+ }
26
+
27
+ /** One error finding of `code` on `file`; `extra` carries the finding's own fields. */
28
+ export const found = (code, file, message, extra = {}) => ({ code, level: 'error', path: file, ...extra, message });
@@ -0,0 +1,54 @@
1
+ // secrets.mjs - HFS_PLAINTEXT_SECRET (R06): secrets exist only as `.starcistacks/<env>/secrets/<slug>.enc` sops envelopes.
2
+ // It judges the tracked tree with the one list of secret shapes the push scan refuses (scripts/lib/secret-patterns.mjs):
3
+ // - a tracked file that is a secret by being (an env file, a key file, a credentials file, a plaintext member of a
4
+ // secrets directory), and every path a forbidden `external` slot names HFS_PLAINTEXT_SECRET for (the slot manifest);
5
+ // - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
6
+ // - a line of any other tracked text file that matches a secret pattern.
7
+ // A finding names the file, the line and the pattern, never the value.
8
+ import { FORBIDDEN_FILES, secretHits } from '../secret-patterns.mjs';
9
+ import { isSopsEnvelope } from '../test-secrets.mjs';
10
+ import { found, readText } from './read.mjs';
11
+
12
+ export const PLAINTEXT_SECRET = 'HFS_PLAINTEXT_SECRET';
13
+ /** Files whose lines are not secrets: a lockfile carries integrity hashes, never a credential. */
14
+ const NOT_SCANNED = /(^|\/)package-lock\.json$/;
15
+
16
+ /** True when the slot `id` of the manifest reports HFS_PLAINTEXT_SECRET for the paths it forbids. */
17
+ export const slotOwnsSecrets = (slot) => slot?.rules?.includes(PLAINTEXT_SECRET) === true;
18
+
19
+ /**
20
+ * The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
21
+ * no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
22
+ * large, absent). The whole-tree check reads the work tree and the commit hook (`hfs work-hygiene`) reads the staged blob: one judgement.
23
+ */
24
+ export function secretFileFindings({ file, text }) {
25
+ const being = FORBIDDEN_FILES.find((rule) => rule.test(file));
26
+ if (being) return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
27
+ if (text === null || text === undefined || NOT_SCANNED.test(file)) return [];
28
+ if (file.endsWith('.enc')) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
29
+ const findings = [];
30
+ const lines = text.split(/\r?\n/);
31
+ const seen = new Set();
32
+ for (let index = 0; index < lines.length; index += 1) {
33
+ for (const pattern of secretHits(file, lines[index])) {
34
+ if (seen.has(pattern)) continue;
35
+ seen.add(pattern);
36
+ findings.push(found(PLAINTEXT_SECRET, file, `${file}:${index + 1} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line: index + 1, pattern }));
37
+ }
38
+ }
39
+ return findings;
40
+ }
41
+
42
+ /** The findings of R06 over `files` (tracked paths) of `repoRoot`; `resolver` names the slots. */
43
+ export function secretFindings({ repoRoot, files, resolver }) {
44
+ const findings = [];
45
+ for (const file of files) {
46
+ const slot = resolver.classifyPath(file);
47
+ if (slot.status === 'forbidden' && slotOwnsSecrets(resolver.slot(slot.slot))) {
48
+ findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${slot.goesTo ? `; it belongs at ${slot.goesTo}` : ''}`, { slot: slot.slot, goesTo: slot.goesTo }));
49
+ continue;
50
+ }
51
+ findings.push(...secretFileFindings({ file, text: readText(repoRoot, file) }));
52
+ }
53
+ return findings;
54
+ }
@@ -0,0 +1,29 @@
1
+ // spec-placement.mjs - BE_SPEC_PLACEMENT (R102): a back-end spec or test file lives in one of the four test layers and nowhere else.
2
+ // - a unit spec is `<name>.service.spec.ts` beside its service, in a slot whose `tests` is `unit-beside` (the slot and the lint rule
3
+ // `unit-test-colocated` judge the name);
4
+ // - integration, e2e and contract specs are `src/tests/{integration,e2e,contract}/...`, the slots whose `tests` is `e2e`;
5
+ // Every other tracked `*.spec.*`, `*.test.*` or `*-spec.*` file is a finding, `scripts/` and `tools/` included: an operational script
6
+ // carries no spec, and a spec that guards one moves into a layer or goes. A file no slot owns (`tools/x.spec.ts`) is judged too, so the
7
+ // finding names the rule rather than only the missing slot. Which folder is a layer is read from the slot manifest, never spelled here.
8
+ import { found } from './read.mjs';
9
+
10
+ export const SPEC_PLACEMENT = 'BE_SPEC_PLACEMENT';
11
+ const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
12
+ const TEST_SLOT_TESTS = new Set(['unit-beside', 'e2e']);
13
+
14
+ /** True for a file name that is a spec or a test. */
15
+ export const isSpecFile = (file) => SPEC_FILE.test(file);
16
+
17
+ /** The findings of R102 over the tracked paths `files` of a back-end repository; `resolver` names the slots. */
18
+ export function specPlacementFindings({ files, resolver }) {
19
+ const findings = [];
20
+ for (const file of files) {
21
+ if (!isSpecFile(file) || file.includes('node_modules/')) continue;
22
+ const classified = resolver.classifyPath(file);
23
+ const slot = classified.status === 'owned' ? resolver.slot(classified.slot) : null;
24
+ if (slot && TEST_SLOT_TESTS.has(slot.tests)) continue;
25
+ const where = slot ? `slot ${slot.id}` : 'no slot';
26
+ findings.push(found(SPEC_PLACEMENT, file, `${file} is a spec outside the test layers (${where}); a unit spec is <name>.service.spec.ts beside its service and an integration, e2e or contract spec sits under src/tests/{integration,e2e,contract}; an operational script or a tool carries no spec`));
27
+ }
28
+ return findings;
29
+ }
@@ -150,7 +150,8 @@ function manifestShapeProblems(m) {
150
150
  const rp = m.ruleParams;
151
151
  if (!isPlainObject(rp) || Object.keys(rp).some((k) => !PROFILES.includes(k)) || !PROFILES.every((p) => isPlainObject(rp[p]))) bad.push('ruleParams must be a map with be and fe');
152
152
  else {
153
- if (!fileLinesOk(rp.be.fileLines) || !blockOk(rp.be.duplicateBlock) || Object.keys(rp.be).length !== 5) bad.push('ruleParams.be needs fileLines {soft, hardGrowth}, duplicateBlock {lines >= 2, tokens >= 1}, infraOwners, suffixes and bannedSuffixes');
153
+ if (!fileLinesOk(rp.be.fileLines) || !blockOk(rp.be.duplicateBlock) || Object.keys(rp.be).length !== 7) bad.push('ruleParams.be needs fileLines {soft, hardGrowth}, duplicateBlock {lines >= 2, tokens >= 1}, infraOwners, specDoubles, suffixes, bannedSuffixes and contractShape {helper}');
154
+ if (!isPlainObject(rp.be.contractShape) || Object.keys(rp.be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(rp.be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
154
155
  const owners = rp.be.infraOwners;
155
156
  const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
156
157
  if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
@@ -158,6 +159,12 @@ function manifestShapeProblems(m) {
158
159
  if (!roleList(rp.be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
159
160
  if (!roleList(rp.be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
160
161
  else if (roleList(rp.be.suffixes) && rp.be.suffixes.some((x) => rp.be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
162
+ const formNames = ['call', 'new', 'curried', 'object', 'primitive', 'array'];
163
+ const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => formNames.includes(x)) && new Set(v).size === v.length;
164
+ const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
165
+ const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
166
+ const sd = rp.be.specDoubles;
167
+ if (!isPlainObject(sd) || Object.keys(sd).sort().join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
161
168
  if (!fileLinesOk(rp.fe.fileLines) || !blockOk(rp.fe.duplicateBlock) || Object.keys(rp.fe).length !== 2) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
162
169
  }
163
170
  if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
@@ -621,12 +628,12 @@ function ruleCatalogProblems(d) {
621
628
  const at = `rules[${index}]`;
622
629
  if (!isPlainObject(r)) { bad.push(`${at} is not a map`); return; }
623
630
  const label = typeof r.id === 'string' ? r.id : at;
624
- for (const key of Object.keys(r)) if (!['id', 'code', 'title_vi', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
625
- const expectedId = `R${String(index + 1).padStart(2, '0')}`;
626
- if (!/^R\d{2}$/.test(String(r.id))) bad.push(`${at}.id must be R<two digits>`);
627
- else if (r.id !== expectedId) bad.push(`${label} is out of order: ${at} must be ${expectedId}`);
631
+ for (const key of Object.keys(r)) if (!['id', 'code', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
632
+ // A retired rule leaves its id unused for good (never reused), so ids only have to increase.
633
+ if (!/^R\d{2,3}$/.test(String(r.id))) bad.push(`${at}.id must be R<two or three digits>`);
634
+ else if (index > 0 && typeof d.rules[index - 1]?.id === 'string' && Number(r.id.slice(1)) <= Number(d.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${d.rules[index - 1].id} comes before it`);
628
635
  if (!FINDING_CODE.test(String(r.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
629
- for (const key of ['title_vi', 'law']) if (typeof r[key] !== 'string' || !r[key].trim()) bad.push(`${label}.${key} is missing`);
636
+ if (typeof r.law !== 'string' || !r.law.trim()) bad.push(`${label}.law is missing`);
630
637
  if (typeof r.law === 'string' && r.law.includes('\n')) bad.push(`${label}.law must be one line`);
631
638
  const enumList = (key, allowed) => {
632
639
  if (!Array.isArray(r[key]) || !r[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
@@ -0,0 +1,22 @@
1
+ // json.mjs — the ledger's forgiving JSON read. Every one of these rows may hold text a peer wrote or a
2
+ // truncated payload, so a malformed column is the caller's fallback, never a throw. The parse of the
3
+ // literal 'null' is kept as null (callers that want {} instead write `parseJsonOr`).
4
+ import fs from 'node:fs';
5
+
6
+ /** `text` as JSON, or `fallback` when it does not parse. */
7
+ export const parseJson = (text, fallback = null) => {
8
+ try { return JSON.parse(text); } catch { return fallback; }
9
+ };
10
+
11
+ /** `text` as JSON, or `fallback` when it does not parse OR parses to null/undefined. Rows written
12
+ * as the literal 'null' read back as the fallback too, so a caller that wants an object gets one. */
13
+ export const parseJsonOr = (text, fallback = {}) => parseJson(text) ?? fallback;
14
+
15
+ /** A jobs/events row spread with `payload`: the row's payload_json parsed via parseJsonOr
16
+ * (`fallback` defaults to {}). Pass fallback null to keep a raw parseJson result, nulls included. */
17
+ export const withPayload = (row, fallback = {}) => (row ? { ...row, payload: parseJsonOr(row.payload_json, fallback) } : row);
18
+
19
+ /** `file` read and parsed as JSON, or `fallback` when the file is missing, unreadable or malformed. */
20
+ export const readJsonFile = (file, fallback = null) => {
21
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
22
+ };