@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,573 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { canonical, isInside, slash } from './config.mjs';
5
+ import { sameOrUnder } from '../../lib/path-key.mjs';
6
+ import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule, sharedInProgramRun, typeScriptProjectReferencePath } from '../typescript-programs.mjs';
7
+ import { readJsonFile } from '../../lib/json.mjs';
8
+
9
+ const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
10
+ const TEST_FILE = /(?:^|[.-])(?:spec|test)\.[cm]?[jt]sx?$/i;
11
+ const ASSET_EXTENSION = /\.(?:css|scss|sass|less|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|eot|ya?ml|json)$/i;
12
+ // Framework build output a tsconfig may include (Next writes `.next/types/**/*.ts` back into tsconfig.json on
13
+ // every build) is compiled for type resolution but is never source: it is gitignored, regenerated, and no
14
+ // canon or architecture rule applies to it (starci-next inc-2260b3754afa, nivo inc-ffe60c49f502).
15
+ export const GENERATED_SEGMENTS = new Set(['.next', '.turbo', '.vercel', '.output', '.nuxt', '.svelte-kit', '.expo', '.docusaurus', '.swc', '.cache']);
16
+ export function isGeneratedPath(root, fileName) {
17
+ return slash(path.relative(root, fileName)).split('/').slice(0, -1).some(segment => GENERATED_SEGMENTS.has(segment));
18
+ }
19
+ // A `<tool>.config.*` or `<tool>.setup.*` module beside a package manifest (next.config.ts,
20
+ // postcss.config.mjs) is build tooling a broad `**/*.ts` include pulls in; a `*.config.ts` inside a source tree
21
+ // (src/config/database.config.ts) has no manifest beside it and stays source. The architecture program still
22
+ // reads tooling modules (a profile may declare one as source); check-scoped-lint does not make one a canon
23
+ // lint subject unless the profile's sourceGlobs name it.
24
+ const TOOLING_MODULE = /^[^/]+\.(?:config|setup)\.[cm]?[jt]sx?$/i;
25
+ export function isToolingModule(fileName) {
26
+ return TOOLING_MODULE.test(path.basename(fileName)) && fs.existsSync(path.join(path.dirname(fileName), 'package.json'));
27
+ }
28
+
29
+ function diagnosticMessage(ts, diagnostic) {
30
+ return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
31
+ }
32
+
33
+ function compilerError(ts, root, diagnostic, project, ruleId = 'ARCH_TSCONFIG_INVALID') {
34
+ const fileName = diagnostic.file?.fileName;
35
+ let line;
36
+ let column;
37
+ if (diagnostic.file && Number.isInteger(diagnostic.start)) {
38
+ const point = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
39
+ line = point.line + 1;
40
+ column = point.character + 1;
41
+ }
42
+ return {
43
+ ruleId,
44
+ project,
45
+ ...(fileName && isInside(root, fileName) ? { path: slash(path.relative(root, fileName)) } : {}),
46
+ ...(line ? { line, column } : {}),
47
+ message: diagnosticMessage(ts, diagnostic),
48
+ };
49
+ }
50
+
51
+ /** Load TypeScript through the target package boundary, never through StarCi's own dependency graph. */
52
+ export function loadTargetTypeScript(repositoryRoot) {
53
+ const packageFile = path.join(repositoryRoot, 'package.json');
54
+ if (!fs.existsSync(packageFile)) throw Error('ARCH_TYPESCRIPT_MISSING: target package.json is required to resolve target-installed TypeScript.');
55
+ const targetRequire = createRequire(packageFile);
56
+ let resolved;
57
+ let ts;
58
+ try {
59
+ resolved = targetRequire.resolve('typescript');
60
+ ts = targetRequire('typescript');
61
+ } catch {
62
+ throw Error('ARCH_TYPESCRIPT_MISSING: install TypeScript in the checked repository; StarCi does not substitute its own parser.');
63
+ }
64
+ if (!ts?.createProgram || !ts?.resolveModuleName || !ts?.readConfigFile) {
65
+ throw Error('ARCH_TYPESCRIPT_INVALID: the target TypeScript package does not expose the compiler API.');
66
+ }
67
+ return { ts, resolved, version: String(ts.version ?? 'unknown') };
68
+ }
69
+
70
+ const readJson = (file) => readJsonFile(file, {});
71
+
72
+ function pathAliasMatches(specifier, paths = {}) {
73
+ return Object.keys(paths).some(pattern => {
74
+ if (!pattern.includes('*')) return pattern === specifier;
75
+ const [before, after] = pattern.split('*');
76
+ return specifier.startsWith(before) && specifier.endsWith(after ?? '');
77
+ });
78
+ }
79
+
80
+ function runtimeImport(ts, node) {
81
+ if (ts.isImportDeclaration(node)) {
82
+ const clause = node.importClause;
83
+ if (!clause) return true;
84
+ if (clause.isTypeOnly) return false;
85
+ if (clause.name) return true;
86
+ const named = clause.namedBindings;
87
+ if (named && ts.isNamedImports(named)) return named.elements.some(element => !element.isTypeOnly);
88
+ return Boolean(named);
89
+ }
90
+ if (ts.isExportDeclaration(node)) {
91
+ if (node.isTypeOnly) return false;
92
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) return node.exportClause.elements.some(element => !element.isTypeOnly);
93
+ }
94
+ return true;
95
+ }
96
+
97
+ function isUnshadowedCommonJsRequire(ts, checker, expression) {
98
+ if (!ts.isIdentifier(expression) || expression.text !== 'require') return false;
99
+ const symbol = checker?.getSymbolAtLocation(expression);
100
+ if (!symbol) return true;
101
+ const declarations = symbol.getDeclarations?.() ?? [];
102
+ return declarations.length > 0 && declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
103
+ }
104
+
105
+ /**
106
+ * The files a template-literal import of data can load. import(`../messages/${locale}.json`) - the
107
+ * next-intl request config - is bundled as a context of every file under ../messages whose name ends in
108
+ * .json, so that set is the dependency, exactly and statically. Only a relative static head naming an
109
+ * existing directory and a data-asset extension tail qualify; code never does, since a context of code would
110
+ * be an import graph nobody wrote down. Returns the specifiers, or null when the import stays unproven.
111
+ */
112
+ export function assetContextSpecifiers(ts, sourceFile, argument) {
113
+ if (!argument || !ts.isTemplateExpression(argument)) return null;
114
+ const head = argument.head.text;
115
+ const spans = argument.templateSpans;
116
+ const tail = spans[spans.length - 1].literal.text;
117
+ if (!/^\.\.?\//.test(head) || !head.endsWith('/') || !/^\.[a-z0-9]+$/i.test(tail) || !ASSET_EXTENSION.test(tail)) return null;
118
+ if (spans.slice(0, -1).some(span => span.literal.text.split('/').includes('..'))) return null;
119
+ const directory = path.resolve(path.dirname(sourceFile.fileName), head);
120
+ let stat;
121
+ try { stat = fs.statSync(directory); } catch { return null; }
122
+ if (!stat.isDirectory()) return null;
123
+ const specifiers = [];
124
+ const visit = (dir) => {
125
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
126
+ if (entry.name === 'node_modules') continue;
127
+ const absolute = path.join(dir, entry.name);
128
+ if (entry.isDirectory()) visit(absolute);
129
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(tail.toLowerCase())) specifiers.push(`${head}${slash(path.relative(directory, absolute))}`);
130
+ }
131
+ };
132
+ visit(directory);
133
+ return specifiers.length ? specifiers : null;
134
+ }
135
+
136
+ function moduleReferences(ts, sourceFile, checker) {
137
+ const found = [];
138
+ const unproven = [];
139
+ const visit = node => {
140
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
141
+ found.push({ node: node.moduleSpecifier, specifier: node.moduleSpecifier.text, runtime: runtimeImport(ts, node), declaration: node });
142
+ } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)
143
+ && node.moduleReference.expression && ts.isStringLiteralLike(node.moduleReference.expression)) {
144
+ found.push({ node: node.moduleReference.expression, specifier: node.moduleReference.expression.text, runtime: !node.isTypeOnly, declaration: node });
145
+ } else if (ts.isImportTypeNode(node)) {
146
+ const literal = ts.isLiteralTypeNode(node.argument) && ts.isStringLiteralLike(node.argument.literal) ? node.argument.literal : null;
147
+ if (literal) found.push({ node: literal, specifier: literal.text, runtime: false, declaration: node });
148
+ else unproven.push({ node, kind: 'TypeScript import type' });
149
+ } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
150
+ const context = [1, 2].includes(node.arguments.length) ? assetContextSpecifiers(ts, sourceFile, node.arguments[0]) : null;
151
+ if ([1, 2].includes(node.arguments.length) && ts.isStringLiteralLike(node.arguments[0])) {
152
+ found.push({ node: node.arguments[0], specifier: node.arguments[0].text, runtime: true, declaration: node });
153
+ } else if (context) {
154
+ for (const specifier of context) found.push({ node: node.arguments[0], specifier, runtime: true, declaration: node });
155
+ } else unproven.push({ node, kind: 'dynamic import()' });
156
+ } else if (ts.isCallExpression(node) && isUnshadowedCommonJsRequire(ts, checker, node.expression)) {
157
+ if (node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])) {
158
+ found.push({ node: node.arguments[0], specifier: node.arguments[0].text, runtime: true, declaration: node });
159
+ } else unproven.push({ node, kind: 'dynamic require()' });
160
+ }
161
+ ts.forEachChild(node, visit);
162
+ };
163
+ visit(sourceFile);
164
+ return { found, unproven };
165
+ }
166
+
167
+ function sourceLocation(sourceFile, node) {
168
+ const point = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
169
+ return { line: point.line + 1, column: point.character + 1 };
170
+ }
171
+
172
+ function isProductionSource(root, sourceFile) {
173
+ return isInside(root, sourceFile.fileName)
174
+ && CODE_EXTENSIONS.test(sourceFile.fileName)
175
+ && !sourceFile.isDeclarationFile
176
+ && !TEST_FILE.test(sourceFile.fileName)
177
+ && !slash(sourceFile.fileName).includes('/node_modules/')
178
+ && !isGeneratedPath(root, sourceFile.fileName);
179
+ }
180
+
181
+ function workspaceMetadata(config) {
182
+ const backendAppRoots = config.backend.apps.map(item => path.join(config.root, ...item.split('/')));
183
+ const create = relative => {
184
+ const root = path.join(config.root, ...relative.split('/'));
185
+ const pkg = readJson(path.join(root, 'package.json'));
186
+ const routeRoots = config.frontend.routes.map(item => path.join(config.root, ...item.split('/')));
187
+ return { root: canonical(root), relative, name: typeof pkg.name === 'string' ? pkg.name : null, exports: pkg.exports, manifest: pkg, workspace: true,
188
+ app: routeRoots.some(route => isInside(root, route))
189
+ || backendAppRoots.some(appRoot => isInside(appRoot, root) || isInside(root, appRoot)) };
190
+ };
191
+ const roots = config.workspaces.map(create);
192
+ const rootPackage = readJson(path.join(config.root, 'package.json'));
193
+ roots.push({ root: canonical(config.root), relative: '.', name: typeof rootPackage.name === 'string' ? rootPackage.name : null, exports: rootPackage.exports,
194
+ app: config.frontend.routes.some(item => isInside(config.root, path.join(config.root, ...item.split('/')))) || config.kinds.includes('backend') });
195
+ return roots.sort((a, b) => b.root.length - a.root.length);
196
+ }
197
+
198
+ function workspaceOf(workspaces, file) {
199
+ return workspaces.find(workspace => isInside(workspace.root, file)) ?? null;
200
+ }
201
+
202
+ function exportPatternCapture(pattern, request) {
203
+ if (!pattern.includes('*')) return pattern === request ? '' : null;
204
+ const [before, after = ''] = pattern.split('*');
205
+ return request.startsWith(before) && request.endsWith(after) ? request.slice(before.length, request.length - after.length) : null;
206
+ }
207
+
208
+ function exportTargetStrings(value) {
209
+ if (typeof value === 'string') return [value];
210
+ if (Array.isArray(value)) return value.flatMap(exportTargetStrings);
211
+ if (value && typeof value === 'object' && !Object.keys(value).some(key => key.startsWith('.'))) return Object.values(value).flatMap(exportTargetStrings);
212
+ return [];
213
+ }
214
+
215
+ /** The package-relative export targets a request (`.` or `./sub`) of a workspace package declares, from exports, else types/main. */
216
+ function exportCandidates(workspace, specifier) {
217
+ if (!workspace.name || !sameOrUnder(specifier, workspace.name)) return [];
218
+ const request = specifier === workspace.name ? '.' : `.${specifier.slice(workspace.name.length)}`;
219
+ const declaration = workspace.exports;
220
+ let candidates = [];
221
+ if (typeof declaration === 'string' || Array.isArray(declaration)) {
222
+ if (request !== '.') return [];
223
+ candidates = exportTargetStrings(declaration);
224
+ } else if (declaration && typeof declaration === 'object') {
225
+ const keys = Object.keys(declaration);
226
+ if (!keys.some(key => key.startsWith('.'))) {
227
+ if (request !== '.') return [];
228
+ candidates = exportTargetStrings(declaration);
229
+ } else {
230
+ for (const key of keys) {
231
+ const capture = exportPatternCapture(key, request);
232
+ if (capture !== null) candidates.push(...exportTargetStrings(declaration[key]).map(target => target.replaceAll('*', capture)));
233
+ }
234
+ }
235
+ }
236
+ return candidates;
237
+ }
238
+
239
+ const SOURCE_EXTENSIONS = { '.d.ts': ['.ts', '.tsx'], '.d.mts': ['.mts'], '.d.cts': ['.cts'], '.js': ['.ts', '.tsx'], '.jsx': ['.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
240
+
241
+ /** rootDir and outDir of a workspace package's build tsconfig (tsconfig.build.json, else tsconfig.json), extends followed; null when it declares none. */
242
+ function workspaceBuildLayout(ts, workspace) {
243
+ if (workspace.layout !== undefined) return workspace.layout;
244
+ workspace.layout = null;
245
+ for (const name of ['tsconfig.build.json', 'tsconfig.json']) {
246
+ const file = path.join(workspace.root, name);
247
+ if (!fs.existsSync(file)) continue;
248
+ const read = ts.readConfigFile(file, ts.sys.readFile);
249
+ if (read.error) continue;
250
+ const { options } = ts.parseJsonConfigFileContent(read.config, ts.sys, workspace.root, undefined, file);
251
+ if (options.outDir) { workspace.layout = { rootDir: options.rootDir ? path.resolve(options.rootDir) : path.join(workspace.root, 'src'), outDir: path.resolve(options.outDir) }; break; }
252
+ }
253
+ return workspace.layout;
254
+ }
255
+
256
+ /** The source file a built export target (`./dist/sub/index.js`, `.d.ts`) stands for: outDir back to rootDir, else the dist-to-src layout; null when none exists. */
257
+ function sourceOfTarget(ts, workspace, target) {
258
+ const absolute = path.resolve(workspace.root, target);
259
+ const layout = workspaceBuildLayout(ts, workspace);
260
+ const extension = Object.keys(SOURCE_EXTENSIONS).filter(item => absolute.endsWith(item)).sort((a, b) => b.length - a.length)[0];
261
+ const sourceRoots = [];
262
+ if (layout && isInside(layout.outDir, absolute)) sourceRoots.push([layout.outDir, layout.rootDir]);
263
+ const first = slash(path.relative(workspace.root, absolute)).split('/')[0];
264
+ if (first) sourceRoots.push([path.join(workspace.root, first), path.join(workspace.root, 'src')]);
265
+ if (!extension) return null;
266
+ for (const [from, to] of sourceRoots) {
267
+ const stem = path.join(to, path.relative(from, absolute)).slice(0, -extension.length);
268
+ for (const candidate of SOURCE_EXTENSIONS[extension]) if (fs.existsSync(stem + candidate)) return canonical(stem + candidate);
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /** The source entry a workspace import resolves to without a build: its export targets mapped back to source, `src/index.ts` for the package root. */
274
+ function workspaceSourceEntry(ts, workspace, specifier) {
275
+ if (!workspace.workspace || !workspace.name || !sameOrUnder(specifier, workspace.name)) return null;
276
+ let candidates = exportCandidates(workspace, specifier);
277
+ if (specifier === workspace.name && !workspace.exports) candidates = [workspace.manifest?.types, workspace.manifest?.typings, workspace.manifest?.module, workspace.manifest?.main].filter(item => typeof item === 'string');
278
+ for (const target of candidates) {
279
+ if (!target.startsWith('./') || target.includes('..')) continue;
280
+ const source = sourceOfTarget(ts, workspace, target);
281
+ if (source) return source;
282
+ }
283
+ if (specifier === workspace.name) {
284
+ for (const name of ['index.ts', 'index.tsx']) if (fs.existsSync(path.join(workspace.root, 'src', name))) return canonical(path.join(workspace.root, 'src', name));
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /** Every source file the package.json `exports` of a workspace package maps (each non-wildcard subpath, dist back to source): its public entries. */
290
+ export function workspaceExportSources(ts, workspace) {
291
+ const declaration = workspace.exports;
292
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return [];
293
+ const sources = new Set();
294
+ for (const [key, value] of Object.entries(declaration)) {
295
+ if (!key.startsWith('.') || key.includes('*')) continue;
296
+ for (const target of exportTargetStrings(value)) {
297
+ if (!target.startsWith('./') || target.includes('..')) continue;
298
+ const source = sourceOfTarget(ts, workspace, target);
299
+ if (source) sources.add(source);
300
+ }
301
+ }
302
+ return [...sources];
303
+ }
304
+
305
+ function packageExported(ts, workspace, specifier, actualTarget) {
306
+ return exportCandidates(workspace, specifier).some(target => {
307
+ if (!target.startsWith('./') || target.includes('..')) return false;
308
+ const expected = canonical(path.resolve(workspace.root, target));
309
+ return expected === canonical(actualTarget) || sourceOfTarget(ts, workspace, target) === canonical(actualTarget);
310
+ });
311
+ }
312
+
313
+ function boundaryViolation(root, edge, fromWorkspace, toWorkspace, ruleId, message) {
314
+ return {
315
+ ruleId,
316
+ path: relativePath(root, edge.from),
317
+ line: edge.line,
318
+ column: edge.column,
319
+ specifier: edge.specifier,
320
+ resolvedPath: relativePath(root, edge.to),
321
+ message,
322
+ fromPackage: fromWorkspace?.name ?? fromWorkspace?.relative,
323
+ toPackage: toWorkspace?.name ?? toWorkspace?.relative,
324
+ };
325
+ }
326
+
327
+ /** Parse configured tsconfigs, then build only selected programs and their project-reference closure. */
328
+ export function buildTypeScriptContext(config, injectedTypeScript, paths = []) {
329
+ const loaded = injectedTypeScript ? { ts: injectedTypeScript, resolved: '(injected test compiler)', version: String(injectedTypeScript.version) }
330
+ : loadTargetTypeScript(config.root);
331
+ // config.hfs is the opened slot resolver (functions), so it never keys a shared value: the repository declaration it was opened
332
+ // from (plain data) identifies it, the manifest being the runtime's one.
333
+ const context = sharedInProgramRun('architecture-context', loaded.ts, { config: { ...config, hfs: config.hfs?.repo ?? null }, paths }, () => typeScriptContext(config, loaded, paths));
334
+ // Callers add to and sort the errors: each gets its own list.
335
+ return { ...context, errors: [...context.errors] };
336
+ }
337
+
338
+ function typeScriptContext(config, loaded, paths) {
339
+ const { ts } = loaded;
340
+ const errors = [];
341
+ const projects = [];
342
+ const parsedProjects = new Map();
343
+ const invalidProjects = new Set();
344
+ const queue = [...config.projects];
345
+ const seenProjects = new Set();
346
+ while (queue.length) {
347
+ const relative = queue.shift();
348
+ if (seenProjects.has(relative)) continue;
349
+ seenProjects.add(relative);
350
+ const configFile = path.join(config.root, ...relative.split('/'));
351
+ if (!fs.existsSync(configFile)) {
352
+ errors.push({ ruleId: 'ARCH_TSCONFIG_MISSING', project: relative, message: `${relative} does not exist.` });
353
+ invalidProjects.add(relative);
354
+ continue;
355
+ }
356
+ const { read, parsed } = readTypeScriptProject(ts, configFile);
357
+ if (read.error) {
358
+ errors.push(compilerError(ts, config.root, read.error, relative));
359
+ invalidProjects.add(relative);
360
+ continue;
361
+ }
362
+ if (parsed.errors.length) {
363
+ errors.push(...parsed.errors.map(item => compilerError(ts, config.root, item, relative)));
364
+ invalidProjects.add(relative);
365
+ continue;
366
+ }
367
+ for (const reference of parsed.projectReferences ?? []) {
368
+ const referencedFile = typeScriptProjectReferencePath(ts, reference);
369
+ const absoluteReference = path.resolve(referencedFile);
370
+ if (!isInside(config.root, absoluteReference)) {
371
+ errors.push({ ruleId: 'ARCH_TSCONFIG_REFERENCE_OUTSIDE', project: relative, message: `Project reference leaves the repository: ${slash(path.relative(config.root, absoluteReference))}.` });
372
+ } else {
373
+ queue.push(slash(path.relative(config.root, absoluteReference)));
374
+ }
375
+ }
376
+ parsedProjects.set(relative, parsed);
377
+ }
378
+ const matches = file => paths.some(prefix => {
379
+ const relative = slash(path.relative(config.root, file));
380
+ const normalized = slash(prefix).replace(/\/$/, '');
381
+ return sameOrUnder(relative, normalized);
382
+ });
383
+ const selected = new Set();
384
+ const direct = new Set();
385
+ if (paths.length) {
386
+ // TypeScript follows imports from these root files. Referenced projects need their declared
387
+ // roots as well, since a project reference need not be an import in the selected source.
388
+ const visit = relative => {
389
+ if (selected.has(relative)) return;
390
+ selected.add(relative);
391
+ for (const reference of parsedProjects.get(relative)?.projectReferences ?? []) {
392
+ const referencedFile = typeScriptProjectReferencePath(ts, reference);
393
+ const referenced = slash(path.relative(config.root, path.resolve(referencedFile)));
394
+ if (parsedProjects.has(referenced)) visit(referenced);
395
+ }
396
+ };
397
+ for (const [relative, parsed] of parsedProjects) if (parsed.fileNames.some(matches)) {
398
+ direct.add(relative);
399
+ visit(relative);
400
+ }
401
+ for (const relative of invalidProjects) {
402
+ const directory = path.dirname(path.join(config.root, ...relative.split('/')));
403
+ if (paths.some(prefix => {
404
+ const absolute = path.join(config.root, ...slash(prefix).split('/'));
405
+ return isInside(directory, absolute);
406
+ })) selected.add(relative);
407
+ }
408
+ errors.splice(0, errors.length, ...errors.filter(error => !error.project || selected.has(error.project)));
409
+ }
410
+ for (const [relative, parsed] of parsedProjects) {
411
+ if (paths.length && !selected.has(relative)) continue;
412
+ const rootNames = paths.length && direct.has(relative) ? parsed.fileNames.filter(matches) : parsed.fileNames;
413
+ const program = createTypeScriptProgram(ts, { rootNames, options: parsed.options, projectReferences: parsed.projectReferences });
414
+ errors.push(...program.getSyntacticDiagnostics().filter(item => !item.file || isInside(config.root, item.file.fileName))
415
+ .map(item => compilerError(ts, config.root, item, relative, 'ARCH_SYNTAX_INVALID')));
416
+ projects.push({ relative, program, options: parsed.options });
417
+ }
418
+ const fileMap = new Map();
419
+ const checkerByFile = new Map();
420
+ const occurrences = new Map();
421
+ for (const project of projects) {
422
+ for (const sourceFile of project.program.getSourceFiles().filter(file => isProductionSource(config.root, file))) {
423
+ const name = canonical(sourceFile.fileName);
424
+ if (!fileMap.has(name)) {
425
+ fileMap.set(name, sourceFile);
426
+ checkerByFile.set(name, project.program.getTypeChecker());
427
+ }
428
+ if (!occurrences.has(name)) occurrences.set(name, []);
429
+ occurrences.get(name).push(project);
430
+ }
431
+ }
432
+ const files = [...fileMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, file]) => file);
433
+ const edges = new Map([...fileMap.keys()].map(file => [file, []]));
434
+ for (const [file, sourceFile] of fileMap) {
435
+ const sourceName = path.resolve(sourceFile.fileName);
436
+ if (sourceName !== file) edges.set(sourceName, edges.get(file));
437
+ }
438
+ const edgeKeys = new Set();
439
+ const host = { ...ts.sys, fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, realpath: ts.sys.realpath };
440
+ const workspaces = workspaceMetadata(config);
441
+ const workspaceNames = new Set(workspaces.map(item => item.name).filter(Boolean));
442
+ for (const [from, sourceFile] of fileMap) {
443
+ const owningWorkspace = workspaceOf(workspaces, from);
444
+ const candidates = occurrences.get(from) ?? [];
445
+ // The deepest project directory holding the file owns its resolution: a root tsconfig without paths must not
446
+ // shadow the app project whose aliases the file actually uses.
447
+ const project = candidates.filter(item => isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), from))
448
+ .sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
449
+ if (!project) continue;
450
+ const references = moduleReferences(ts, sourceFile, project.program.getTypeChecker());
451
+ for (const item of references.unproven) errors.push({
452
+ ruleId: 'ARCH_DYNAMIC_DEPENDENCY_UNPROVEN',
453
+ project: project.relative,
454
+ path: relativePath(config.root, sourceFile.fileName),
455
+ ...sourceLocation(sourceFile, item.node),
456
+ message: `${item.kind} must use a string-literal module name so architecture coverage can resolve its dependency.`,
457
+ });
458
+ for (const reference of references.found) {
459
+ // A workspace package is read at its source, never at its build: the same import resolves whether or not dist exists.
460
+ const workspaceTarget = workspaces.filter(item => item.root !== canonical(config.root)).map(item => workspaceSourceEntry(ts, item, reference.specifier)).find(Boolean);
461
+ const resolvedName = workspaceTarget ?? resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host);
462
+ if (!resolvedName) {
463
+ const codeLike = !ASSET_EXTENSION.test(reference.specifier);
464
+ const workspaceImport = [...workspaceNames].some(name => sameOrUnder(reference.specifier, name));
465
+ const internal = reference.specifier.startsWith('.') || pathAliasMatches(reference.specifier, project.options.paths) || workspaceImport;
466
+ if (codeLike && internal) {
467
+ errors.push({
468
+ ruleId: 'ARCH_INTERNAL_IMPORT_UNRESOLVED',
469
+ project: project.relative,
470
+ path: relativePath(config.root, sourceFile.fileName),
471
+ ...sourceLocation(sourceFile, reference.node),
472
+ specifier: reference.specifier,
473
+ message: `Internal import ${reference.specifier} is not resolvable with ${project.relative}.`,
474
+ });
475
+ }
476
+ continue;
477
+ }
478
+ const actualTarget = canonical(resolvedName);
479
+ const workspaceImport = [...workspaceNames].some(name => reference.specifier === name || reference.specifier.startsWith(`${name}/`));
480
+ const internal = reference.specifier.startsWith('.') || pathAliasMatches(reference.specifier, project.options.paths) || workspaceImport;
481
+ // The boundary is the checkout, not the checked project: a path alias that lands in a sibling
482
+ // package of the same repository (`@fe-kit/*` -> packages/fe-kit) is still source a reviewer can
483
+ // open, while anything past the repository, or anything inside an installed dependency tree, is
484
+ // not. `config.repository` is null when there is no git checkout around the project, and the
485
+ // project is then the boundary it always was.
486
+ const reviewable = isInside(config.root, actualTarget)
487
+ || (config.repository && isInside(config.repository, actualTarget) && !slash(actualTarget).includes('/node_modules/'));
488
+ if (internal && !reviewable) {
489
+ errors.push({
490
+ ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
491
+ project: project.relative,
492
+ path: relativePath(config.root, sourceFile.fileName),
493
+ ...sourceLocation(sourceFile, reference.node),
494
+ specifier: reference.specifier,
495
+ message: `Internal import ${reference.specifier} resolves outside the checked repository.`,
496
+ });
497
+ continue;
498
+ }
499
+ if (!fileMap.has(actualTarget)) continue;
500
+ const edge = {
501
+ from,
502
+ to: actualTarget,
503
+ runtime: reference.runtime,
504
+ specifier: reference.specifier,
505
+ node: reference.node,
506
+ declaration: reference.declaration,
507
+ reexport: ts.isExportDeclaration(reference.declaration),
508
+ sourceFile,
509
+ project: project.relative,
510
+ ...sourceLocation(sourceFile, reference.node),
511
+ };
512
+ const key = `${from}\0${actualTarget}\0${reference.node.getStart(sourceFile)}\0${reference.specifier}`;
513
+ if (!edgeKeys.has(key)) { edges.get(from).push(edge); edgeKeys.add(key); }
514
+ const targetWorkspace = workspaceOf(workspaces, actualTarget);
515
+ if (owningWorkspace && targetWorkspace && owningWorkspace !== targetWorkspace) {
516
+ if (!owningWorkspace.app && targetWorkspace.app) {
517
+ errors.push(boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_IMPORTS_APP', 'A reusable workspace package cannot depend on an application workspace.'));
518
+ }
519
+ if (!packageExported(ts, targetWorkspace, reference.specifier, actualTarget)) {
520
+ errors.push(boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_EXPORT_BYPASS', 'Cross-package imports must use the target package name and a declared package export.'));
521
+ }
522
+ }
523
+ }
524
+ }
525
+ if (projects.length && files.length === 0) errors.push({ ruleId: 'ARCH_NO_SOURCE', message: 'The configured TypeScript projects contain no production TypeScript or JavaScript source.' });
526
+ return { loaded, errors, files, edges, programs: projects.map(item => item.program), program: projects[0]?.program ?? null, projects, ts, workspaces,
527
+ checkerFor: file => checkerByFile.get(canonical(file)) ?? null,
528
+ workspaceOf: file => workspaceOf(workspaces, canonical(file)) };
529
+ }
530
+
531
+ export function relativePath(root, fileName) {
532
+ return slash(path.relative(root, fileName));
533
+ }
534
+
535
+ export function reachableViolation(edges, firstEdge, forbidden, { follow = () => true } = {}) {
536
+ const queue = [{ file: firstEdge.to, chain: [firstEdge.from, firstEdge.to] }];
537
+ const visited = new Set();
538
+ while (queue.length) {
539
+ const current = queue.shift();
540
+ if (visited.has(current.file)) continue;
541
+ visited.add(current.file);
542
+ if (forbidden(current.file)) return current.chain;
543
+ for (const edge of edges.get(current.file) ?? []) if (follow(edge)) queue.push({ file: edge.to, chain: [...current.chain, edge.to] });
544
+ }
545
+ return null;
546
+ }
547
+
548
+ /** A framework identity the checkers cannot prove statically. */
549
+ export const UNPROVEN_FRAMEWORK = '(unproven framework identity)';
550
+
551
+ /** The expression under parentheses, type assertions, non-null and satisfies wrappers. */
552
+ export function unwrapExpression(ts, expression) {
553
+ while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
554
+ || ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression)
555
+ || (ts.isSatisfiesExpression?.(expression) ?? false))) expression = expression.expression;
556
+ return expression;
557
+ }
558
+
559
+ /** The names of `expected` an import or re-export statement binds (all of them for a namespace or star form). */
560
+ export function referencedExports(ts, statement, expected) {
561
+ if (ts.isImportDeclaration(statement)) {
562
+ const bindings = statement.importClause?.namedBindings;
563
+ if (!bindings || ts.isNamespaceImport(bindings)) return bindings ? [...expected] : [];
564
+ return bindings.elements.map(element => element.propertyName?.text ?? element.name.text).filter(name => expected.has(name));
565
+ }
566
+ if (ts.isExportDeclaration(statement)) {
567
+ if (!statement.exportClause || ts.isNamespaceExport(statement.exportClause)) return [...expected];
568
+ return statement.exportClause.elements.map(element => element.propertyName?.text ?? element.name.text).filter(name => expected.has(name));
569
+ }
570
+ return [...expected];
571
+ }
572
+
573
+ export { isUnshadowedCommonJsRequire, sourceLocation };