@starci/eslint-canon-fe 7.1.2 → 8.0.1

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 (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/client-boundary.mjs +70 -0
  3. package/docs/client-boundary.md +24 -0
  4. package/lib/config.mjs +1 -1
  5. package/lib/hfs.mjs +7 -6
  6. package/lib/next.mjs +2 -2
  7. package/lib/why.mjs +12 -2
  8. package/package.json +1 -1
  9. package/props-and-slots.mjs +120 -0
  10. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  11. package/runtime/knowledge/hfs/slots.yaml +193 -128
  12. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  13. package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
  14. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  15. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  16. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  17. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  18. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  19. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  20. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  21. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  22. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  23. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  24. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  25. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  26. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  27. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  28. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  29. package/runtime/scripts/lib/language.mjs +11 -1
  30. package/runtime/scripts/lib/next-contract.mjs +2 -2
  31. package/transport.mjs +3 -3
  32. package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
  33. package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
@@ -8,7 +8,9 @@
8
8
  * exported `const Y = X` / `const Y = X.y` (a bare identifier or member that resolves to a function,
9
9
  * class, const or enum of the repository), `type Y = X` and `interface Y extends X {}` (no body, no
10
10
  * type arguments, no type parameters): a second name for one declaration. Rename the declaration,
11
- * or import it by its name.
11
+ * or import it by its name. A route file (slot fe.route) binding a declaration to a name Next.js
12
+ * requires of a route segment (generateMetadata, generateStaticParams, ...) is not a second name: the
13
+ * framework fixes that name, so no rename can remove it.
12
14
  * Specs and tests are not in the graph. The public entry of an owner is found the way dead-exports.mjs finds it.
13
15
  */
14
16
  import { canonical } from './config.mjs';
@@ -145,14 +147,23 @@ function declarationAliases(ts, checker, graph, statement) {
145
147
  return found;
146
148
  }
147
149
 
150
+ /** The names Next.js reads from a route segment file (layout, page, route, ...): the framework fixes them. */
151
+ const NEXT_SEGMENT_EXPORTS = new Set([
152
+ 'generateMetadata', 'metadata', 'generateViewport', 'viewport', 'generateStaticParams', 'generateImageMetadata', 'generateSitemaps',
153
+ 'dynamic', 'dynamicParams', 'revalidate', 'fetchCache', 'runtime', 'preferredRegion', 'maxDuration',
154
+ ]);
155
+ const ROUTE_SLOT = 'fe.route';
156
+
148
157
  function aliasReexports({ context, graph }) {
149
158
  const kind = context.ts.SyntaxKind;
150
159
  const violations = [];
151
160
  for (const [rel, node] of graph.files) {
161
+ const frameworkName = name => node.slot === ROUTE_SLOT && NEXT_SEGMENT_EXPORTS.has(name);
152
162
  const point = target => node.sourceFile.getLineAndCharacterOfPosition(target.getStart(node.sourceFile));
153
163
  const checker = context.checkerFor(node.abs);
154
164
  for (const statement of node.sourceFile.statements) {
155
165
  for (const alias of declarationAliases(context.ts, checker, graph, statement)) {
166
+ if (frameworkName(alias.name)) continue;
156
167
  const at = point(alias.node);
157
168
  violations.push({
158
169
  ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1, name: alias.name, aliasOf: alias.of,
@@ -170,7 +181,7 @@ function aliasReexports({ context, graph }) {
170
181
  continue;
171
182
  }
172
183
  for (const element of clause.elements) {
173
- if (!element.propertyName || element.propertyName.text === element.name.text) continue;
184
+ if (!element.propertyName || element.propertyName.text === element.name.text || frameworkName(element.name.text)) continue;
174
185
  const at = point(element);
175
186
  violations.push({
176
187
  ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1,
@@ -3,25 +3,30 @@ import { allowsFile } from '../../lib/hfs-allows.mjs';
3
3
  import { DEFAULT_ENVIRONMENT, STACKS_DIRECTORY, STATEFUL_KINDS, namesOfService, readStack } from '../../lib/stack-services.mjs';
4
4
 
5
5
  /**
6
- * R47 `test-world-files` (BE_TEST_TOPOLOGY). Two judgements over the test world, read through slots, the repository's own
6
+ * R47 `test-world-files` (BE_TEST_TOPOLOGY). Judgements over the test world, read through slots, the repository's own
7
7
  * stack definition (`.starcistacks/<env>`, scripts/lib/stack-services.mjs) and the world's declaration
8
- * (`test-world.config.ts` of the test-world library), never through a path or a name list:
8
+ * (`test-world.config.ts`, read in the shape @starci/test-world defines), never through a path or a name list:
9
9
  *
10
10
  * 1. Files. `src/tests/world/` is the only test infrastructure location, and it holds only what its slot `allows`
11
11
  * (knowledge/hfs/slots.yaml be.tests.world: global-setup.ts, global-teardown.ts, use-test-world.ts, and fakes/; kit/ is
12
12
  * its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
13
13
  * (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
14
14
  * matched by the slot's own entries through `allowsFile`.
15
- * 2. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world;
16
- * `fakes/<provider>/` holds a network-edge fake of an external SaaS the team does not operate. A `fakes/<provider>/` that
17
- * fakes a stack service (its own name, its image repository or a well-known alias of the image) is refused, except the one
18
- * owner-approved exception: a stack service that is stateless compute needing special hardware or an external model
19
- * (GPU inference, a self-hosted embedding model) and that the config declares in `fakedBy`:
20
- * `fakedBy: { "<stack service>": { fake: "<fakes/ folder>", reason: "<non-empty>" } }`. A stack service of a stateful
21
- * kind (database, cache, identity, storage, mail, queue, search) or with a persistent volume is never accepted, and a
22
- * `fakedBy` entry that names a stack service or a fake folder that does not exist, or has no reason, is refused as stale
23
- * or empty. The config's `stacks` names the stack environments the world runs (default `dev`); the machine reads the
24
- * literal object of the config through the TypeScript AST and nothing else of it.
15
+ * 2. The declaration. `test-world.config.ts` declares the world in ONE form, the named export R89 allows:
16
+ * `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })` (an exported `const` initialized by a
17
+ * `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
18
+ * read) is refused: the world it declares could not be judged.
19
+ * 3. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world; a fake
20
+ * (a `fakes/<provider>/` folder, or a `fakes` entry of the declaration such as `smtp: smtpFake()`) is a network-edge fake
21
+ * of an external SaaS the team does not operate. A fake of a stack service (its own name, its image repository or a
22
+ * well-known alias of the image) is refused, except the one owner-approved exception: a stack service that is stateless
23
+ * compute needing special hardware or an external model (GPU inference, a self-hosted embedding model) whose `stacks` entry
24
+ * declares it faked: `stacks: { "<stack service>": { fakedBy: "<fake>", reason: "<non-empty>" } }`. A stack service of a
25
+ * stateful kind (database, cache, identity, storage, mail, queue, search) or with a persistent volume is never accepted, and
26
+ * an entry that names a stack service or a fake (a `fakes` entry or a `fakes/` folder) that does not exist, or has no
27
+ * reason, is refused as stale or empty. The declaration's `stack` (`".starcistacks/<env>"`) names the stack environment the
28
+ * world runs (default `dev`); the machine reads the literal object of the declaration through the TypeScript AST and
29
+ * nothing else of it.
25
30
  */
26
31
  export const TEST_WORLD_FILES_RULE_IDS = ['BE_TEST_TOPOLOGY'];
27
32
 
@@ -29,6 +34,8 @@ const RULE = 'BE_TEST_TOPOLOGY';
29
34
  const WORLD_SLOT = 'be.tests.world';
30
35
  const FAKES_DIRECTORY = 'fakes/';
31
36
  const CONFIG_FILE = 'test-world.config.ts';
37
+ const DEFINE = 'defineTestWorld';
38
+ const NAMED_FORM = `export const { useTestWorld, useSandbox } = ${DEFINE}({ ... })`;
32
39
  const defaultEnvironment = DEFAULT_ENVIRONMENT;
33
40
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
34
41
 
@@ -39,51 +46,70 @@ const unwrap = (ts, node) => {
39
46
  return current;
40
47
  };
41
48
  const propertyOf = (ts, literal, key) => literal.properties.find(property => ts.isPropertyAssignment(property) && nameOf(ts, property.name) === key)?.initializer ?? null;
49
+ const isExported = (ts, statement) => (statement.modifiers ?? []).some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
50
+ const positionOf = (sourceFile, node) => {
51
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
52
+ return { line: position.line + 1, column: position.character + 1 };
53
+ };
54
+
55
+ /** The object literal of `defineTestWorld(<object literal>)`, or null. */
56
+ function defineLiteral(ts, expression) {
57
+ const call = unwrap(ts, expression);
58
+ if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== DEFINE) return null;
59
+ const [first] = call.arguments;
60
+ const literal = first ? unwrap(ts, first) : null;
61
+ return literal && ts.isObjectLiteralExpression(literal) ? literal : null;
62
+ }
42
63
 
43
- /** The object literal a config file declares: `export default { ... }` or `export default defineTestWorld({ ... })`. */
64
+ /** The declaration of the world: the object literal of an exported `const` initialized by `defineTestWorld({ ... })`, or null. */
44
65
  function configLiteral(ts, sourceFile) {
45
66
  for (const statement of sourceFile.statements) {
46
- if (!ts.isExportAssignment(statement)) continue;
47
- const expression = unwrap(ts, statement.expression);
48
- if (ts.isObjectLiteralExpression(expression)) return expression;
49
- if (ts.isCallExpression(expression)) {
50
- const [first] = expression.arguments;
51
- const literal = first ? unwrap(ts, first) : null;
52
- if (literal && ts.isObjectLiteralExpression(literal)) return literal;
67
+ if (!ts.isVariableStatement(statement) || !isExported(ts, statement)) continue;
68
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
69
+ for (const declaration of statement.declarationList.declarations) {
70
+ const literal = declaration.initializer ? defineLiteral(ts, declaration.initializer) : null;
71
+ if (literal) return literal;
53
72
  }
54
73
  }
55
74
  return null;
56
75
  }
57
76
 
58
- /** `stacks` as a list of environment names: an array of strings, or the keys of an object literal. */
59
- function stacksOf(ts, literal) {
60
- const node = literal ? propertyOf(ts, literal, 'stacks') : null;
77
+ /** `stack` as the environment it names: the last segment of `".starcistacks/<env>"`; null when absent or not a literal. */
78
+ function environmentOf(ts, literal) {
79
+ const node = literal ? propertyOf(ts, literal, 'stack') : null;
61
80
  const value = node ? unwrap(ts, node) : null;
62
- if (!value) return null;
63
- if (ts.isArrayLiteralExpression(value)) return value.elements.filter(element => ts.isStringLiteralLike(element)).map(element => element.text);
64
- if (ts.isObjectLiteralExpression(value)) return value.properties.map(property => nameOf(ts, property.name)).filter(Boolean);
65
- return null;
81
+ if (!value || !ts.isStringLiteralLike(value)) return null;
82
+ return value.text.split(/[\\/]+/u).filter(Boolean).at(-1) ?? null;
66
83
  }
67
84
 
68
- /** `fakedBy` entries: [{service, fake, reason, line, column}]; a value the reader cannot read statically has an empty fake and reason. */
85
+ /** The keys of the declaration's `fakes` object literal, with their positions. */
86
+ function fakeEntriesOf(ts, sourceFile, literal) {
87
+ const node = literal ? propertyOf(ts, literal, 'fakes') : null;
88
+ const value = node ? unwrap(ts, node) : null;
89
+ if (!value || !ts.isObjectLiteralExpression(value)) return [];
90
+ return value.properties
91
+ .map(property => ({ name: nameOf(ts, property.name), ...positionOf(sourceFile, property) }))
92
+ .filter(entry => entry.name);
93
+ }
94
+
95
+ /** The `stacks` entries that declare `fakedBy`: [{service, fake, reason, line, column}]; a value the reader cannot read statically has an empty fake and reason. */
69
96
  function fakedByOf(ts, sourceFile, literal) {
70
- const node = literal ? propertyOf(ts, literal, 'fakedBy') : null;
97
+ const node = literal ? propertyOf(ts, literal, 'stacks') : null;
71
98
  const value = node ? unwrap(ts, node) : null;
72
99
  if (!value || !ts.isObjectLiteralExpression(value)) return [];
73
100
  const entries = [];
74
101
  for (const property of value.properties) {
75
102
  if (!ts.isPropertyAssignment(property)) continue;
76
- const service = nameOf(ts, property.name);
77
103
  const body = unwrap(ts, property.initializer);
78
- const fake = ts.isObjectLiteralExpression(body) ? propertyOf(ts, body, 'fake') : null;
79
- const reason = ts.isObjectLiteralExpression(body) ? propertyOf(ts, body, 'reason') : null;
80
- const position = sourceFile.getLineAndCharacterOfPosition(property.getStart(sourceFile));
104
+ if (!body || !ts.isObjectLiteralExpression(body)) continue;
105
+ const fake = propertyOf(ts, body, 'fakedBy');
106
+ if (!fake) continue;
107
+ const reason = propertyOf(ts, body, 'reason');
81
108
  entries.push({
82
- service,
83
- fake: fake && ts.isStringLiteralLike(fake) ? fake.text : '',
109
+ service: nameOf(ts, property.name),
110
+ fake: ts.isStringLiteralLike(fake) ? fake.text : '',
84
111
  reason: reason && ts.isStringLiteralLike(reason) ? reason.text.trim() : '',
85
- line: position.line + 1,
86
- column: position.character + 1,
112
+ ...positionOf(sourceFile, property),
87
113
  });
88
114
  }
89
115
  return entries;
@@ -123,7 +149,13 @@ export function checkTestWorldFiles(input) {
123
149
  const configPath = worldRoot === null ? null : `${worldRoot}${CONFIG_FILE}`;
124
150
  const configFile = configPath === null ? null : graph.files.get(configPath) ?? null;
125
151
  const literal = configFile ? configLiteral(ts, configFile.sourceFile) : null;
126
- const environments = (literal ? stacksOf(ts, literal) : null) ?? [defaultEnvironment];
152
+ if (configFile && !literal) {
153
+ violations.push({
154
+ ruleId: RULE, path: configPath, line: 1, column: 1, slot: WORLD_SLOT,
155
+ message: `${configPath} declares no world the machine can read. Declare it in the one named form \`${NAMED_FORM}\` (an exported const of a ${DEFINE} call with an object literal, imported from @starci/test-world); a default export is refused by R89 and is not read.`,
156
+ });
157
+ }
158
+ const environments = [(literal ? environmentOf(ts, literal) : null) ?? defaultEnvironment];
127
159
  const services = [];
128
160
  for (const environment of environments) {
129
161
  let stack = null;
@@ -132,14 +164,15 @@ export function checkTestWorldFiles(input) {
132
164
  }
133
165
  const stackHint = `${STACKS_DIRECTORY}/${environments.join(', ')}`;
134
166
  const statefulWhy = service => (STATEFUL_KINDS.has(service.kind) ? `a ${service.kind} holds data the app reads back` : service.persistent ? 'the stack gives it a persistent volume' : null);
135
- const declared = configFile ? fakedByOf(ts, configFile.sourceFile, literal) : [];
167
+ const declared = literal ? fakedByOf(ts, configFile.sourceFile, literal) : [];
168
+ const fakeEntries = literal ? fakeEntriesOf(ts, configFile.sourceFile, literal) : [];
136
169
 
137
170
  for (const entry of declared) {
138
171
  const service = services.find(candidate => candidate.name === entry.service);
139
172
  const problems = [];
140
173
  if (!service) problems.push(`${stackHint} declares no service ${entry.service}`);
141
174
  else if (statefulWhy(service)) problems.push(`${service.name} is stateful (${statefulWhy(service)}) and always runs real`);
142
- if (!fakeProviders.has(entry.fake)) problems.push(`there is no fakes/${entry.fake || '<fake>'}/ folder`);
175
+ if (!fakeProviders.has(entry.fake) && !fakeEntries.some(fake => fake.name === entry.fake)) problems.push(`there is no fakes entry and no fakes/${entry.fake || '<fake>'}/ folder of that name`);
143
176
  if (entry.reason === '') problems.push('the reason is empty');
144
177
  if (problems.length > 0) {
145
178
  violations.push({
@@ -149,14 +182,19 @@ export function checkTestWorldFiles(input) {
149
182
  }
150
183
  }
151
184
 
152
- for (const [provider, file] of [...fakeProviders].sort(([a], [b]) => a.localeCompare(b))) {
153
- const service = services.find(candidate => namesOfService(candidate).includes(provider));
185
+ // Every fake the world runs: the repository's fakes/<provider>/ folders and the fakes entries of the declaration.
186
+ const fakes = [
187
+ ...[...fakeProviders].map(([provider, file]) => ({ name: provider, path: file, line: 1, column: 1, what: `fakes/${provider}/` })),
188
+ ...fakeEntries.filter(entry => !fakeProviders.has(entry.name)).map(entry => ({ name: entry.name, path: configPath, line: entry.line, column: entry.column, what: `the fakes entry ${entry.name}` })),
189
+ ].sort((a, b) => a.name.localeCompare(b.name));
190
+ for (const fake of fakes) {
191
+ const service = services.find(candidate => namesOfService(candidate).includes(fake.name));
154
192
  if (!service) continue;
155
- if (declared.some(entry => entry.service === service.name && entry.fake === provider)) continue; // judged on its config entry above
193
+ if (declared.some(entry => entry.service === service.name && entry.fake === fake.name)) continue; // judged on its stacks entry above
156
194
  const why = statefulWhy(service);
157
195
  violations.push({
158
- ruleId: RULE, path: file, line: 1, column: 1, slot: WORLD_SLOT,
159
- message: `fakes/${provider}/ fakes ${service.name} (${service.image}), which ${stackHint} declares: a service of the repository's own stack runs real in the test world. ${why ? `${service.name} is stateful (${why}), so no exception applies. ` : `Only stateless compute that needs special hardware or an external model may be faked, and only when ${configPath ?? `${CONFIG_FILE} in the world`} declares it in fakedBy with a reason. `}Delete the fake and let the world run the real service; fakes/ is otherwise only for external SaaS the team does not operate.`,
196
+ ruleId: RULE, path: fake.path, line: fake.line, column: fake.column, slot: WORLD_SLOT,
197
+ message: `${fake.what} fakes ${service.name} (${service.image}), which ${stackHint} declares: a service of the repository's own stack runs real in the test world. ${why ? `${service.name} is stateful (${why}), so no exception applies. ` : `Only stateless compute that needs special hardware or an external model may be faked, and only when ${configPath ?? `${CONFIG_FILE} in the world`} declares it in its stacks entry ({ fakedBy, reason }). `}Delete the fake and let the world run the real service; fakes are otherwise only for external SaaS the team does not operate.`,
160
198
  });
161
199
  }
162
200
  return { violations, coverage: { status: 'checked', files } };
@@ -5,6 +5,7 @@ import { canonical, isInside, slash } from './config.mjs';
5
5
  import { sameOrUnder } from '../../lib/path-key.mjs';
6
6
  import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule, sharedInProgramRun, typeScriptProjectReferencePath } from '../typescript-programs.mjs';
7
7
  import { readJsonFile } from '../../lib/json.mjs';
8
+ import { locateDeclaration } from '../../lib/hfs-slots.mjs';
8
9
 
9
10
  const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
10
11
  const TEST_FILE = /(?:^|[.-])(?:spec|test)\.[cm]?[jt]sx?$/i;
@@ -16,14 +17,14 @@ export const GENERATED_SEGMENTS = new Set(['.next', '.turbo', '.vercel', '.outpu
16
17
  export function isGeneratedPath(root, fileName) {
17
18
  return slash(path.relative(root, fileName)).split('/').slice(0, -1).some(segment => GENERATED_SEGMENTS.has(segment));
18
19
  }
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.
20
+ // A `<tool>.config.*` or `<tool>.setup.*` module at the root of a project (beside a package manifest or a tsconfig.json:
21
+ // next.config.ts and postcss.config.mjs of an app, which has no package.json of its own) is build tooling a broad `**/*.ts`
22
+ // include pulls in; a `*.config.ts` inside a source tree (src/config/database.config.ts) has neither beside it and stays source.
23
+ // The architecture program still reads tooling modules (a profile may declare one as source); the lint (hfs lint) judges one only
24
+ // through the repository's own eslint.config.
24
25
  const TOOLING_MODULE = /^[^/]+\.(?:config|setup)\.[cm]?[jt]sx?$/i;
25
26
  export function isToolingModule(fileName) {
26
- return TOOLING_MODULE.test(path.basename(fileName)) && fs.existsSync(path.join(path.dirname(fileName), 'package.json'));
27
+ return TOOLING_MODULE.test(path.basename(fileName)) && ['package.json', 'tsconfig.json'].some((manifest) => fs.existsSync(path.join(path.dirname(fileName), manifest)));
27
28
  }
28
29
 
29
30
  function diagnosticMessage(ts, diagnostic) {
@@ -48,9 +49,12 @@ function compilerError(ts, root, diagnostic, project, ruleId = 'ARCH_TSCONFIG_IN
48
49
  };
49
50
  }
50
51
 
51
- /** Load TypeScript through the target package boundary, never through StarCi's own dependency graph. */
52
+ /**
53
+ * Load TypeScript through the target package boundary, never through StarCi's own dependency graph. A side folder of an app has no
54
+ * package.json of its own: the app root's one manifest is the boundary its TypeScript is installed under.
55
+ */
52
56
  export function loadTargetTypeScript(repositoryRoot) {
53
- const packageFile = path.join(repositoryRoot, 'package.json');
57
+ const packageFile = path.join(locateDeclaration(repositoryRoot).appRoot, 'package.json');
54
58
  if (!fs.existsSync(packageFile)) throw Error('ARCH_TYPESCRIPT_MISSING: target package.json is required to resolve target-installed TypeScript.');
55
59
  const targetRequire = createRequire(packageFile);
56
60
  let resolved;
@@ -189,7 +193,8 @@ function workspaceMetadata(config) {
189
193
  || backendAppRoots.some(appRoot => isInside(appRoot, root) || isInside(root, appRoot)) };
190
194
  };
191
195
  const roots = config.workspaces.map(create);
192
- const rootPackage = readJson(path.join(config.root, 'package.json'));
196
+ // A side of an app has no package.json: the app root's one manifest names the package the side's root files belong to.
197
+ const rootPackage = readJson(path.join(config.packageRoot ?? config.root, 'package.json')) ?? {};
193
198
  roots.push({ root: canonical(config.root), relative: '.', name: typeof rootPackage.name === 'string' ? rootPackage.name : null, exports: rootPackage.exports,
194
199
  app: config.frontend.routes.some(item => isInside(config.root, path.join(config.root, ...item.split('/')))) || config.kinds.includes('backend') });
195
200
  return roots.sort((a, b) => b.root.length - a.root.length);
@@ -415,20 +420,29 @@ function typeScriptContext(config, loaded, paths) {
415
420
  .map(item => compilerError(ts, config.root, item, relative, 'ARCH_SYNTAX_INVALID')));
416
421
  projects.push({ relative, program, options: parsed.options });
417
422
  }
418
- const fileMap = new Map();
419
- const checkerByFile = new Map();
420
423
  const occurrences = new Map();
424
+ const sourceIn = new Map();
421
425
  for (const project of projects) {
422
426
  for (const sourceFile of project.program.getSourceFiles().filter(file => isProductionSource(config.root, file))) {
423
427
  const name = canonical(sourceFile.fileName);
424
- if (!fileMap.has(name)) {
425
- fileMap.set(name, sourceFile);
426
- checkerByFile.set(name, project.program.getTypeChecker());
427
- }
428
428
  if (!occurrences.has(name)) occurrences.set(name, []);
429
429
  occurrences.get(name).push(project);
430
+ sourceIn.set(`${project.relative}|${name}`, sourceFile);
430
431
  }
431
432
  }
433
+ // The deepest project directory holding a file owns it: its source file, its checker and its resolution. A side or repository
434
+ // root tsconfig without paths must not shadow the app project whose aliases (`@/*`) the file actually uses.
435
+ const owningProject = (candidates, file) => candidates.filter(item => isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), file))
436
+ .sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
437
+ const fileMap = new Map();
438
+ const checkerByFile = new Map();
439
+ const ownerByFile = new Map();
440
+ for (const [name, candidates] of occurrences) {
441
+ const owner = owningProject(candidates, name);
442
+ ownerByFile.set(name, owner);
443
+ fileMap.set(name, sourceIn.get(`${owner.relative}|${name}`));
444
+ checkerByFile.set(name, owner.program.getTypeChecker());
445
+ }
432
446
  const files = [...fileMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, file]) => file);
433
447
  const edges = new Map([...fileMap.keys()].map(file => [file, []]));
434
448
  for (const [file, sourceFile] of fileMap) {
@@ -441,11 +455,7 @@ function typeScriptContext(config, loaded, paths) {
441
455
  const workspaceNames = new Set(workspaces.map(item => item.name).filter(Boolean));
442
456
  for (const [from, sourceFile] of fileMap) {
443
457
  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];
458
+ const project = ownerByFile.get(from);
449
459
  if (!project) continue;
450
460
  const references = moduleReferences(ts, sourceFile, project.program.getTypeChecker());
451
461
  for (const item of references.unproven) errors.push({
@@ -485,6 +495,21 @@ function typeScriptContext(config, loaded, paths) {
485
495
  // project is then the boundary it always was.
486
496
  const reviewable = isInside(config.root, actualTarget)
487
497
  || (config.repository && isInside(config.repository, actualTarget) && !slash(actualTarget).includes('/node_modules/'));
498
+ // A side of an app (config.root is be/ or fe/) imports nothing of the app outside itself: the root holds no source, the other
499
+ // side is another program, and a declared read (sides.fe.reads, be/contracts/) is codegen input, never an import.
500
+ const crossesSide = config.packageRoot !== undefined && config.packageRoot !== config.root
501
+ && isInside(config.packageRoot, actualTarget) && !isInside(config.root, actualTarget) && !slash(actualTarget).includes('/node_modules/');
502
+ if (internal && crossesSide) {
503
+ errors.push({
504
+ ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
505
+ project: project.relative,
506
+ path: relativePath(config.root, sourceFile.fileName),
507
+ ...sourceLocation(sourceFile, reference.node),
508
+ specifier: reference.specifier,
509
+ message: `Internal import ${reference.specifier} leaves the ${path.basename(config.root)} side for ${slash(path.relative(config.packageRoot, actualTarget))}; nothing crosses the sides of an app.`,
510
+ });
511
+ continue;
512
+ }
488
513
  if (internal && !reviewable) {
489
514
  errors.push({
490
515
  ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
@@ -4,8 +4,8 @@ import path from 'node:path';
4
4
 
5
5
  // One TypeScript program per (compiler, root names, compiler options, project references) inside a program run, and
6
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
7
+ // A caller (canon-scan's architecture machine, a script checker) opens one run around its work and releases it after,
8
+ // so nothing built here outlives the run that built it. Outside a run every call
9
9
  // builds afresh.
10
10
  const runs = new AsyncLocalStorage();
11
11
 
@@ -8,11 +8,18 @@ import { allowsFile } from './hfs-allows.mjs';
8
8
  import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
9
9
  import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
10
10
  import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
11
+ import { APP_SCOPE } from './hfs-slots.mjs';
11
12
 
12
13
  const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
14
  const SOURCE_ROOT = /^(?:src|apps)\//;
14
15
  const FREE_NAMES = new Set(['index.ts', 'main.ts']);
15
16
  const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
17
+ /**
18
+ * Where the work of a banned data-access suffix goes. The suffix is banned by the manifest (ruleParams.be.bannedSuffixes);
19
+ * this only adds the convention's home to the finding, so a file that is a repository or a store is told what replaces it.
20
+ */
21
+ const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
22
+ const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
16
23
 
17
24
  /**
18
25
  * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
@@ -39,14 +46,16 @@ function sourceFormFindings({ files, resolver }) {
39
46
  // A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
40
47
  const slot = resolver.slot(c.slot);
41
48
  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.
49
+ // So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
43
50
  const admitted = allowsFile(resolver, file);
51
+ if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
52
+ // 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.
44
53
  if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
45
54
  if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
46
55
  const parts = base.slice(0, -'.ts'.length).split('.');
47
56
  const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
48
57
  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(', ')})` });
58
+ 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(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
50
59
  } else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
51
60
  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
61
  } else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
@@ -79,6 +88,8 @@ export function pathFindings({ files, resolver, profile }) {
79
88
  }
80
89
 
81
90
  if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
91
+ // The app root (scripts/, CI, hooks) holds no test layer: a spec there is the same finding as a spec of an operational script.
92
+ else if (profile === APP_SCOPE) findings.push(...specPlacementFindings({ files, resolver }));
82
93
  // The lint canon's project graph serves these findings on a TypeScript file; the origin keeps them apart from the machine's.
83
94
  return findings.map((finding) => ({ ...finding, origin: 'repo' }));
84
95
  }