@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,107 @@
1
+ import path from 'node:path';
2
+ import { machineKit } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R46 `background-unowned` (BE_BACKGROUND_UNOWNED, BE-CONVENTION 1.6 and 1.13). Background work is a transport that only
6
+ * a worker app runs:
7
+ *
8
+ * - every job (`*.job.ts`) and consumer (`*.consumer.ts`) is composed by an app of kind `worker`: the file is reachable,
9
+ * through runtime imports, from the root module of some worker app declared in hfs.json; one no worker reaches never runs;
10
+ * - no scheduler outside `platform/scheduling`: a `@Cron`, `@Interval` or `@Timeout` decorator of `@nestjs/schedule` and a
11
+ * `setInterval` call are refused everywhere else (platform/scheduling owns the lease, the fencing and the overlap guard);
12
+ * - a method whose name is one of the words the law names (`sweep`, `deliver`, `reconcile`, `retry`, alone or as the
13
+ * first word of a camelCase name) in a domain, integrations or feature class is reachable from a composed job or
14
+ * consumer: from the job or consumer files and everything they import, and from the files of the feature that holds them
15
+ * (its handlers are registered with the command bus, not imported by the job). Mechanisms of platform are exempt.
16
+ *
17
+ * Reachability is static (imports, re-exports and the feature of the job); it proves a job or consumer could call the method,
18
+ * not that it does.
19
+ */
20
+ export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
21
+
22
+ const RULE = 'BE_BACKGROUND_UNOWNED';
23
+ const BACKGROUND_ROLES = ['job', 'consumer'];
24
+ /** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
25
+ const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
26
+ const SCHEDULER_DECORATORS = ['Cron', 'Interval', 'Timeout'];
27
+ const SCHEDULER_PACKAGE = '@nestjs/schedule';
28
+ const SCHEDULING_CAPABILITY = 'scheduling';
29
+
30
+ const roleOf = rel => {
31
+ const parts = path.posix.basename(rel).split('.');
32
+ return parts.length >= 3 && parts.at(-1) === 'ts' && BACKGROUND_ROLES.includes(parts.at(-2)) ? parts.at(-2) : null;
33
+ };
34
+ const isBackgroundName = name => BACKGROUND_WORDS.some(word => name === word || (name.startsWith(word) && /[A-Z]/u.test(name[word.length] ?? '')));
35
+
36
+ export function checkBackgroundUnowned(input) {
37
+ const { graph } = input;
38
+ const kit = machineKit(input);
39
+ const { ts, resolver } = kit;
40
+ const violations = [];
41
+ const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
42
+
43
+ const adjacency = new Map();
44
+ for (const edge of graph.edges) if (edge.runtime) {
45
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
46
+ adjacency.get(edge.from).push(edge.to);
47
+ }
48
+ const closure = starts => {
49
+ const seen = new Set(starts);
50
+ const queue = [...starts];
51
+ while (queue.length) for (const next of adjacency.get(queue.shift()) ?? []) if (!seen.has(next)) { seen.add(next); queue.push(next); }
52
+ return seen;
53
+ };
54
+
55
+ const workerRoots = resolver.repo.apps.filter(app => app.kind === 'worker').map(app => `apps/${app.name}/src/app.module.ts`).filter(rel => graph.files.has(rel));
56
+ const composed = closure(workerRoots);
57
+ const background = [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
58
+ const running = background.filter(file => composed.has(file.rel));
59
+ for (const file of background) {
60
+ if (composed.has(file.rel)) continue;
61
+ const role = roleOf(file.rel);
62
+ report(file, file.sourceFile, `${file.rel} is a ${role} that no worker app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'job' ? 'schedule' : 'message'} module of its feature is imported by the root module of an app of kind worker.`, { role });
63
+ }
64
+
65
+ // Everything a composed job or consumer can reach: its imports, and the files of its own feature (command handlers).
66
+ const roots = new Set();
67
+ for (const file of running) {
68
+ roots.add(file.rel);
69
+ const owner = file.owner?.root;
70
+ if (owner) for (const other of graph.files.values()) if (other.owner?.root === owner) roots.add(other.rel);
71
+ }
72
+ const reachable = closure([...roots]);
73
+
74
+ let methods = 0;
75
+ for (const file of graph.files.values()) {
76
+ const checker = kit.checkerOf(file.sourceFile);
77
+ const inScheduling = file.owner?.slot === 'be.platform' && path.posix.basename(file.owner.root) === SCHEDULING_CAPABILITY;
78
+ const mechanism = file.tier === 'platform';
79
+ kit.walk(file.sourceFile, node => {
80
+ if (!inScheduling && ts.isDecorator(node)) {
81
+ const call = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
82
+ const binding = kit.importBinding(checker, call);
83
+ if (binding && binding.module === SCHEDULER_PACKAGE && SCHEDULER_DECORATORS.includes(binding.name)) {
84
+ report(file, node, `@${binding.name} schedules work inside a class outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts that dispatches one command or query, and let platform/scheduling and a worker app run it.`, { scheduler: binding.name });
85
+ }
86
+ }
87
+ if (!inScheduling && ts.isCallExpression(node)) {
88
+ const callee = node.expression;
89
+ const bare = ts.isIdentifier(callee) && callee.text === 'setInterval' && kit.declarationsOf(checker, callee).every(declaration => declaration.getSourceFile().isDeclarationFile);
90
+ const member = ts.isPropertyAccessExpression(callee) && callee.name.text === 'setInterval' && ts.isIdentifier(callee.expression) && ['globalThis', 'window', 'global'].includes(callee.expression.text);
91
+ const global = bare || member;
92
+ if (global) report(file, node, 'setInterval starts a scheduler outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts and let platform/scheduling and a worker app run it.', { scheduler: 'setInterval' });
93
+ }
94
+ if (!mechanism && ts.isClassDeclaration(node)) {
95
+ for (const member of node.members) {
96
+ if (!ts.isMethodDeclaration(member) || !member.name) continue;
97
+ const name = kit.propertyNameText(member.name);
98
+ if (!name || !isBackgroundName(name)) continue;
99
+ methods += 1;
100
+ if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no job or consumer composed by a worker app can reach. Add a job in transport/schedule or a consumer in transport/message of a feature that runs it, and compose that transport module in a worker app.`, { method: name });
101
+ }
102
+ }
103
+ return true;
104
+ });
105
+ }
106
+ return { violations, coverage: { status: 'checked', workers: workerRoots.length, jobsAndConsumers: background.length, composed: running.length, backgroundMethods: methods } };
107
+ }
@@ -0,0 +1,94 @@
1
+ import { builtinModules } from 'node:module';
2
+ import { sourceLocation } from './typescript.mjs';
3
+
4
+ /**
5
+ * R55 `client-reaches-server` (FE_CLIENT_REACHES_SERVER), the repository half of the eslint rule `client-no-server-import`,
6
+ * which sees one hop from a file that carries the directive. The machine walks the resolved import graph from every module
7
+ * whose first statement is the `"use client"` directive, over runtime edges only (a type-only import vanishes at build), and
8
+ * reports any reachable module (the entry itself included) that imports a server-only surface:
9
+ *
10
+ * - `server-only`, `next/headers`, `next/server`, `next-intl/server`;
11
+ * - a Node built-in: `node:*` and every bare name of `module.builtinModules` (`fs`, `path`, `crypto`, ...).
12
+ *
13
+ * The finding sits on the client entry, names the shortest import chain from it to the offending module (multi-hop) and the
14
+ * specifier. Only imports that reach the client bundle count: `import type`, an import whose every named specifier is
15
+ * `type`, and `export type` re-exports are skipped.
16
+ */
17
+ export const CLIENT_REACHES_SERVER_RULE_IDS = ['FE_CLIENT_REACHES_SERVER'];
18
+
19
+ const RULE = 'FE_CLIENT_REACHES_SERVER';
20
+ const SERVER_MODULES = new Set(['server-only', 'next/headers', 'next/server', 'next-intl/server']);
21
+ const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u, '').split('/')[0]));
22
+
23
+ const isServerSpecifier = specifier => SERVER_MODULES.has(specifier) || specifier.startsWith('node:') || NODE_BUILTINS.has(specifier.split('/')[0]);
24
+
25
+ export function checkClientReachesServer({ graph, context }) {
26
+ const ts = context.ts;
27
+ const isClientEntry = file => {
28
+ const first = file.sourceFile.statements[0];
29
+ return Boolean(first) && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === 'use client';
30
+ };
31
+ /** True when the import or re-export declaration reaches the bundle: not `import type`, not all-`type` specifiers. */
32
+ const reachesBundle = declaration => {
33
+ if (ts.isImportDeclaration(declaration)) {
34
+ const clause = declaration.importClause;
35
+ if (!clause) return true;
36
+ if (clause.isTypeOnly) return false;
37
+ if (!clause.name && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
38
+ const elements = clause.namedBindings.elements;
39
+ return elements.length === 0 || !elements.every(element => element.isTypeOnly);
40
+ }
41
+ return true;
42
+ }
43
+ return !declaration.isTypeOnly;
44
+ };
45
+ /** The server-only specifiers a file imports, with the node to point at. */
46
+ const serverImports = file => {
47
+ const found = [];
48
+ const visit = node => {
49
+ const specifier = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && reachesBundle(node) ? node.moduleSpecifier
50
+ : ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require')) ? node.arguments[0]
51
+ : null;
52
+ if (specifier && ts.isStringLiteralLike(specifier) && isServerSpecifier(specifier.text)) found.push({ specifier: specifier.text, node });
53
+ ts.forEachChild(node, visit);
54
+ };
55
+ visit(file.sourceFile);
56
+ return found;
57
+ };
58
+
59
+ const runtime = new Map();
60
+ for (const edge of graph.edges) {
61
+ if (!edge.runtime) continue;
62
+ if (!runtime.has(edge.from)) runtime.set(edge.from, []);
63
+ runtime.get(edge.from).push(edge);
64
+ }
65
+ const serverByFile = new Map();
66
+ const serverOf = rel => {
67
+ if (!serverByFile.has(rel)) serverByFile.set(rel, serverImports(graph.files.get(rel)));
68
+ return serverByFile.get(rel);
69
+ };
70
+
71
+ const violations = [];
72
+ const entries = [...graph.files.values()].filter(isClientEntry).sort((a, b) => a.rel.localeCompare(b.rel));
73
+ for (const entry of entries) {
74
+ const seen = new Map([[entry.rel, { chain: [entry.rel], first: null }]]);
75
+ const queue = [entry.rel];
76
+ for (let head = 0; head < queue.length; head += 1) {
77
+ const rel = queue[head];
78
+ const state = seen.get(rel);
79
+ for (const hit of serverOf(rel)) {
80
+ const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
81
+ violations.push({
82
+ ruleId: RULE, path: entry.rel, line: at.line, column: at.column, clientEntry: entry.rel, importer: rel, specifier: hit.specifier, dependencyChain: state.chain,
83
+ message: `${entry.rel} is a client module ("use client") and reaches ${hit.specifier} through ${[...state.chain, hit.specifier].join(' -> ')}; a server-only surface never enters the client bundle. Read on the server and pass the value down, or move the import out of the client graph.`,
84
+ });
85
+ }
86
+ for (const edge of runtime.get(rel) ?? []) {
87
+ if (seen.has(edge.to)) continue;
88
+ seen.set(edge.to, { chain: [...state.chain, edge.to], first: state.first ?? { line: edge.line, column: edge.column } });
89
+ queue.push(edge.to);
90
+ }
91
+ }
92
+ }
93
+ return { violations, coverage: { status: 'checked', clientEntries: entries.length } };
94
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * HFS check 7, R21 duplicate code (knowledge/hfs/slots.yaml ruleParams.<profile>.duplicateBlock = {lines: N, tokens: T},
3
+ * the one definition of the threshold): a token-normalised block of at least N source lines and T tokens that appears
4
+ * twice in the production program - in two owners, in two files of one owner, or twice in one file - is code copied
5
+ * instead of shared.
6
+ *
7
+ * Normalisation: every production file is flattened into its syntax-node sequence (the compiler's parsed tree, walked in
8
+ * source order; comments and whitespace do not exist in it), identifiers become one ID kind, string, template, numeric,
9
+ * bigint and regex literals become one LIT kind, keywords and operators keep their own kind. Leading import and
10
+ * export-from statements are dropped (imports repeat legitimately). A window is the run of tokens on N consecutive
11
+ * physical lines starting at a line start whose last token reaches line N; windows are hashed with a rolling polynomial
12
+ * hash (O(total tokens)), equal windows of two different owners are verified token by token, and overlapping hits on the
13
+ * same file pair and offset merge into one maximal block. A block whose lines are mostly type declarations
14
+ * (fewer than N/2 lines outside interface and type alias declarations) or of fewer than N/3 distinct line shapes
15
+ * (a decorated DTO field list repeating one shape) is ignored.
16
+ *
17
+ * Two kinds of file are uniform by design and are not compared, both found through the slot resolver: the entry main.ts of
18
+ * an app (slot appKind + its required main.ts) and the transport doors of a feature (a *.controller.ts, *.resolver.ts,
19
+ * *.gateway.ts, *.consumer.ts, *.job.ts or *.cli.ts of a slot that a protocol app composes). This is not a loophole: logic
20
+ * cannot live in either. A door body is dispatch only (eslint transport-is-thin, R88), and main.ts cannot share a
21
+ * helper because NestFactory is allowed only in main.ts (BE_ENTRYPOINT_ONLY_IN_APPS), so what repeats there is the
22
+ * canonical shape of section 5 of BE-CONVENTION. A door or main.ts that grows logic is refused by those enforcers, not
23
+ * excused here; every other file of the same slots (mappers, dto, app.module.ts) stays compared.
24
+ */
25
+ export const CLONE_RULE_IDS = ['HFS_DUPLICATE_CODE'];
26
+
27
+ const MAX_VIOLATIONS = 200;
28
+ const DOOR_ROLES = new Set(['controller', 'resolver', 'gateway', 'consumer', 'job', 'cli']);
29
+ const APP_ENTRY = 'main.ts';
30
+ const ID = -1;
31
+ const LIT = -2;
32
+
33
+ function tokenize(ts, sourceFile) {
34
+ const { SyntaxKind } = ts;
35
+ const literalKinds = new Set([SyntaxKind.StringLiteral, SyntaxKind.NumericLiteral, SyntaxKind.BigIntLiteral, SyntaxKind.RegularExpressionLiteral,
36
+ SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TemplateHead, SyntaxKind.TemplateMiddle, SyntaxKind.TemplateTail, SyntaxKind.JsxText]);
37
+ const kinds = [];
38
+ const lines = [];
39
+ const types = [];
40
+ const lineStarts = sourceFile.getLineStarts();
41
+ const lineOf = (position) => {
42
+ let low = 0;
43
+ let high = lineStarts.length - 1;
44
+ while (low < high) {
45
+ const mid = (low + high + 1) >> 1;
46
+ if (lineStarts[mid] <= position) low = mid; else high = mid - 1;
47
+ }
48
+ return low + 1;
49
+ };
50
+ const visit = (node, inType) => {
51
+ const kind = node.kind;
52
+ if (kind >= SyntaxKind.FirstJSDocNode && kind <= SyntaxKind.LastJSDocNode) return;
53
+ if (kind === SyntaxKind.JsxText && node.containsOnlyTriviaWhiteSpaces) return;
54
+ const typeScope = inType || kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration;
55
+ kinds.push(kind === SyntaxKind.Identifier || kind === SyntaxKind.PrivateIdentifier ? ID : literalKinds.has(kind) ? LIT : kind);
56
+ lines.push(lineOf(node.getStart(sourceFile)));
57
+ types.push(typeScope ? 1 : 0);
58
+ ts.forEachChild(node, (child) => { visit(child, typeScope); });
59
+ };
60
+ for (const statement of sourceFile.statements) {
61
+ if (statement.kind === SyntaxKind.ImportDeclaration || statement.kind === SyntaxKind.ImportEqualsDeclaration
62
+ || (statement.kind === SyntaxKind.ExportDeclaration && statement.moduleSpecifier)) continue;
63
+ visit(statement, false);
64
+ }
65
+ return { kinds: Int32Array.from(kinds), lines: Int32Array.from(lines), types: Uint8Array.from(types) };
66
+ }
67
+
68
+ /** The app a file belongs to, by the slot binding of the manifest; null for a file of no app. */
69
+ const appOfFile = (resolver, rel) => resolver.classifyPath(rel).bindings?.app ?? null;
70
+
71
+ /** True for the app entry and the transport doors: their shape is fixed by the convention and their body cannot hold logic. */
72
+ function uniformByDesign(resolver, rel) {
73
+ const classified = resolver.classifyPath(rel);
74
+ if (classified.status !== 'owned' || !classified.slot) return false;
75
+ const slot = resolver.slot(classified.slot);
76
+ const base = rel.slice(rel.lastIndexOf('/') + 1);
77
+ if (slot.appKind !== undefined) return base === APP_ENTRY && (slot.requires ?? []).includes(APP_ENTRY);
78
+ const role = base.split('.').at(-2);
79
+ return (slot.composedBy?.length ?? 0) > 0 && DOOR_ROLES.has(role) && base.endsWith('.ts');
80
+ }
81
+
82
+ function homeText(profile, sameOwner, crossApp) {
83
+ if (crossApp) return 'move it to a package (packages/<pkg>, the shared-code slot of the repository) and import it from both apps';
84
+ if (sameOwner) return 'extract it once inside the owner and call it from both places';
85
+ if (profile === 'fe') return 'extract it once inside the app (apps/<app>/src/modules/<capability>/ when pure, apps/<app>/src/hooks/<domain>/ for a React hook) and import it from both';
86
+ return "move the shared helper to src/modules/platform/primitives/ or into the owning capability's src/modules/domain/<capability>/ and import it from both";
87
+ }
88
+
89
+ export function checkClones({ config, context, graph } = {}) {
90
+ const { lines: N, tokens: T } = graph.resolver.ruleParams().duplicateBlock;
91
+ const ts = context.ts ?? context.loaded.ts;
92
+ const entries = [];
93
+ for (const rel of [...graph.files.keys()].sort()) {
94
+ if (/\.d\.[cm]?tsx?$/.test(rel) || uniformByDesign(graph.resolver, rel)) continue;
95
+ const unit = graph.unit(rel);
96
+ if (!unit) continue;
97
+ entries.push({ rel, unit, ...tokenize(ts, graph.files.get(rel).sourceFile) });
98
+ }
99
+ // Rolling hash: two independent 32-bit polynomial prefix hashes, verified by token comparison on every match.
100
+ const B1 = 1000003;
101
+ const B2 = 92821;
102
+ let maxTokens = 0;
103
+ for (const file of entries) maxTokens = Math.max(maxTokens, file.kinds.length);
104
+ const pow1 = new Int32Array(maxTokens + 1);
105
+ const pow2 = new Int32Array(maxTokens + 1);
106
+ pow1[0] = 1; pow2[0] = 1;
107
+ for (let i = 1; i <= maxTokens; i += 1) { pow1[i] = Math.imul(pow1[i - 1], B1); pow2[i] = Math.imul(pow2[i - 1], B2); }
108
+ const buckets = new Map();
109
+ entries.forEach((file, fileIndex) => {
110
+ const { kinds, lines } = file;
111
+ const count = kinds.length;
112
+ const pre1 = new Int32Array(count + 1);
113
+ const pre2 = new Int32Array(count + 1);
114
+ for (let i = 0; i < count; i += 1) {
115
+ pre1[i + 1] = (Math.imul(pre1[i], B1) + kinds[i] + 3) | 0;
116
+ pre2[i + 1] = (Math.imul(pre2[i], B2) + kinds[i] + 7) | 0;
117
+ }
118
+ let end = 0;
119
+ for (let start = 0; start < count; start += 1) {
120
+ if (start > 0 && lines[start] === lines[start - 1]) continue;
121
+ const limit = lines[start] + N;
122
+ if (end < start) end = start;
123
+ while (end < count && lines[end] < limit) end += 1;
124
+ if (end === start || lines[end - 1] - lines[start] + 1 < N) continue;
125
+ const length = end - start;
126
+ if (length < T) continue;
127
+ const key = `${(pre1[end] - Math.imul(pre1[start], pow1[length])) | 0}:${(pre2[end] - Math.imul(pre2[start], pow2[length])) | 0}:${length}`;
128
+ const list = buckets.get(key);
129
+ const item = [fileIndex, start, end];
130
+ if (list) list.push(item); else buckets.set(key, [item]);
131
+ }
132
+ });
133
+ const same = (a, b) => {
134
+ const ka = entries[a[0]].kinds;
135
+ const kb = entries[b[0]].kinds;
136
+ for (let i = 0; i < a[2] - a[1]; i += 1) if (ka[a[1] + i] !== kb[b[1] + i]) return false;
137
+ return true;
138
+ };
139
+ // Hits per (file a, file b, offset): merged into maximal blocks below.
140
+ const hits = new Map();
141
+ for (const list of buckets.values()) {
142
+ if (list.length < 2) continue;
143
+ for (const item of list) {
144
+ // The first other occurrence is the partner; overlapping windows of one file are one occurrence, not a copy.
145
+ const partner = list.find((other) => other !== item && (other[0] !== item[0] || other[1] >= item[2] || item[1] >= other[2]));
146
+ if (!partner || !same(item, partner)) continue;
147
+ const order = entries[item[0]].rel === entries[partner[0]].rel ? item[1] < partner[1] : entries[item[0]].rel < entries[partner[0]].rel;
148
+ const [a, b] = order ? [item, partner] : [partner, item];
149
+ const key = `${a[0]}|${b[0]}|${b[1] - a[1]}`;
150
+ const group = hits.get(key);
151
+ if (group) group.push([a[1], a[2]]); else hits.set(key, [[a[1], a[2]]]);
152
+ }
153
+ }
154
+ const blocks = [];
155
+ // Shape of a block: the lines outside type declarations and the number of distinct line shapes (a field list of
156
+ // one decorated DTO shape repeated N times is boilerplate, not a copied helper).
157
+ const blockShape = (file, start, end) => {
158
+ const nonType = new Set();
159
+ const shapes = new Map();
160
+ for (let i = start; i < end; i += 1) {
161
+ if (!file.types[i]) nonType.add(file.lines[i]);
162
+ shapes.set(file.lines[i], ((shapes.get(file.lines[i]) ?? 7) * 31 + file.kinds[i] + 5) | 0);
163
+ }
164
+ return { nonType: nonType.size, distinct: new Set(shapes.values()).size };
165
+ };
166
+ for (const [key, group] of hits) {
167
+ const [ai, bi, diagText] = key.split('|');
168
+ const fileA = entries[Number(ai)];
169
+ const fileB = entries[Number(bi)];
170
+ const diag = Number(diagText);
171
+ group.sort((x, y) => x[0] - y[0]);
172
+ let [start, end] = group[0];
173
+ const flush = () => {
174
+ const shape = blockShape(fileA, start, end);
175
+ if (shape.nonType < Math.ceil(N / 2) || shape.distinct < Math.ceil(N / 3)) return;
176
+ const lineA = fileA.lines[start];
177
+ const endA = fileA.lines[end - 1];
178
+ blocks.push({ a: fileA, b: fileB, line: lineA, endLine: endA, lines: endA - lineA + 1,
179
+ twin: { path: fileB.rel, line: fileB.lines[start + diag], endLine: fileB.lines[end - 1 + diag] } });
180
+ };
181
+ for (const [nextStart, nextEnd] of group.slice(1)) {
182
+ if (nextStart <= end) { end = Math.max(end, nextEnd); continue; }
183
+ flush();
184
+ start = nextStart; end = nextEnd;
185
+ }
186
+ flush();
187
+ }
188
+ blocks.sort((x, y) => x.a.rel.localeCompare(y.a.rel) || x.line - y.line || x.b.rel.localeCompare(y.b.rel));
189
+ const profile = graph.profile;
190
+ const differentApps = (a, b) => { const left = appOfFile(graph.resolver, a); const right = appOfFile(graph.resolver, b); return left !== null && right !== null && left !== right; };
191
+ const violations = blocks.slice(0, MAX_VIOLATIONS).map((block) => ({
192
+ ruleId: 'HFS_DUPLICATE_CODE', path: block.a.rel, line: block.line, endLine: block.endLine, twin: block.twin, lines: block.lines,
193
+ message: `${block.a.rel}:${block.line}-${block.endLine} duplicates ${block.twin.path}:${block.twin.line}-${block.twin.endLine} (${block.lines} lines, threshold ${N} lines / ${T} tokens, identifiers and literals ignored); ${homeText(profile, block.a.unit === block.b.unit, differentApps(block.a.rel, block.b.rel))}.`,
194
+ }));
195
+ const owners = new Set();
196
+ let duplicatedLines = 0;
197
+ for (const block of blocks) { owners.add(block.a.unit); owners.add(block.b.unit); duplicatedLines += block.lines; }
198
+ return { violations, coverage: { status: 'checked', minLines: N, minTokens: T, files: entries.length, cloneBlocks: blocks.length, ownersInvolved: owners.size, duplicatedLines,
199
+ ...(blocks.length > MAX_VIOLATIONS ? { truncated: true } : {}) } };
200
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ import { treeOf } from './required-files.mjs';
3
+
4
+ /**
5
+ * R24 `arch-config-unread` (HFS_ARCH_CONFIG_UNREAD). The machine reads `hfs.json` and nothing else; a config file it
6
+ * no longer reads is dead text that says a rule is tuned when it is not, and a declaration that yields no analysed file
7
+ * is a machine that judged nothing. Both are red:
8
+ *
9
+ * - a tracked `architecture.json` (the retired per-repository config; the declaration is hfs.json, the rest is derived
10
+ * from the slot manifest);
11
+ * - a repository whose declaration and tsconfig load but whose program holds no production source file.
12
+ */
13
+ export const CONFIG_UNREAD_RULE_IDS = ['HFS_ARCH_CONFIG_UNREAD'];
14
+
15
+ const RULE = 'HFS_ARCH_CONFIG_UNREAD';
16
+ /** Config file names an earlier machine read and this one never does. */
17
+ const RETIRED_CONFIG_NAMES = ['architecture.json'];
18
+
19
+ export function checkConfigUnread({ config, context }) {
20
+ const violations = [];
21
+ const tree = treeOf(config.root);
22
+ let retired = 0;
23
+ for (const file of [...tree.files].sort()) {
24
+ if (!RETIRED_CONFIG_NAMES.includes(path.posix.basename(file))) continue;
25
+ retired += 1;
26
+ violations.push({ ruleId: RULE, path: file, line: 1, column: 1,
27
+ message: `${file} is a config the architecture machine no longer reads. hfs.json declares the repository; owners, roots, tiers and budgets come from the slot manifest. Delete ${file}.` });
28
+ }
29
+ const analysed = context.files.length;
30
+ if (analysed === 0) {
31
+ violations.push({ ruleId: RULE, path: 'hfs.json', line: 1, column: 1,
32
+ message: 'hfs.json and the TypeScript projects load but the machine analysed no production source file, so it judged nothing. Declare the apps that hold source in hfs.json, or fix the tsconfig include so the source is in the program.' });
33
+ }
34
+ return { violations, coverage: { status: 'checked', retired, analysed } };
35
+ }