@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
@@ -0,0 +1,22 @@
1
+ {
2
+ "scripts": {
3
+ "prepare": "husky",
4
+ {{appScripts}}
5
+ "build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
6
+ "build:fe": "{{buildFe}}",
7
+ "codegen": "node scripts/codegen.mjs",
8
+ "typecheck": "{{typecheck}}",
9
+ "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
10
+ "lint": "npm run codegen --silent && hfs lint",
11
+ "lint:fix": "npm run codegen --silent && hfs lint --fix",
12
+ "format": "prettier --write .",
13
+ "format:check": "prettier --check .",
14
+ "test": "cd be && jest --selectProjects unit --coverage",
15
+ "test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
16
+ "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
17
+ "test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
18
+ "test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
19
+ "test:stack": "cd be && starci-test-stack",
20
+ "contract:emit": "hfs emit-contracts"
21
+ }
22
+ }
@@ -61,7 +61,12 @@ function existingRegularFile(root, relative) {
61
61
  }
62
62
  }
63
63
 
64
- function workspaceDirectories(root) {
64
+ /**
65
+ * The npm workspaces below `root`. The one package.json of an app is at `packageRoot` (the app root; `root` is its side folder):
66
+ * its workspace patterns under `<side>/` are this side's, read relative to the side folder, and every other pattern is the other
67
+ * side's.
68
+ */
69
+ function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side = null } = {}) {
65
70
  const directories = new Set();
66
71
  const queue = [root];
67
72
  const visited = new Set();
@@ -89,11 +94,14 @@ function workspaceDirectories(root) {
89
94
  const packageRoot = queue.shift();
90
95
  if (visited.has(packageRoot)) continue;
91
96
  visited.add(packageRoot);
92
- const pkg = readJson(path.join(packageRoot, 'package.json'));
97
+ const sideRoot = side !== null && packageRoot === root;
98
+ const pkg = readJson(path.join(sideRoot ? appPackageRoot : packageRoot, 'package.json'));
93
99
  const patterns = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
94
100
  for (const pattern of Array.isArray(patterns) ? patterns : []) {
95
101
  if (typeof pattern !== 'string' || !pattern.trim()) throw Error('package.json workspace entries must be non-empty paths.');
96
- const normalized = slash(pattern.trim()).replace(/^\.\//, '');
102
+ const written = slash(pattern.trim()).replace(/^\.\//, '');
103
+ if (sideRoot && !written.startsWith(`${side}/`)) continue;
104
+ const normalized = sideRoot ? written.slice(side.length + 1) : written;
97
105
  const segments = normalized.split('/');
98
106
  if (path.isAbsolute(normalized) || segments.some(segment => segment === '..' || (segment.includes('*') && segment !== '*'))) {
99
107
  throw Error(`Unsupported local workspace pattern: ${normalized}.`);
@@ -121,7 +129,10 @@ function workspaceDirectories(root) {
121
129
  for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
122
130
  for (const [name, value] of Object.entries(pkg?.[section] ?? {})) {
123
131
  if (typeof value !== 'string' || !value.startsWith('file:')) continue;
124
- const absolute = path.resolve(packageRoot, value.slice('file:'.length));
132
+ const absolute = path.resolve(sideRoot ? appPackageRoot : packageRoot, value.slice('file:'.length));
133
+ // The app's one package.json also lists the other side's file dependencies (inside the app, outside this side): not this
134
+ // side's. A path that leaves the app is judged like any other: a package of the same repository, or refused.
135
+ if (sideRoot && !isInside(root, absolute) && isInside(appPackageRoot, absolute)) continue;
125
136
  const label = `${section}.${name} file dependency`;
126
137
  if (isInside(root, absolute)) { admit(absolute, label, true); continue; }
127
138
  // A sibling package of the same repository: this project consumes it, so the path is real and
@@ -248,15 +259,19 @@ const GRAMMAR_PACKAGE = '@starci/grammar';
248
259
  * source, every app and every workspace package that depends on the Grammar package is a consumer. null when no app has
249
260
  * a globals.css to judge (the contract is then reported unavailable, never passed).
250
261
  */
251
- function derivedGrammar(root, apps, workspaces) {
262
+ function derivedGrammar(root, packageRoot, workspaces, apps = []) {
252
263
  const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
253
264
  if (!styleSources.length) return null;
254
- const consumerManifests = apps.map(app => `apps/${app.name}/package.json`).filter(relative => existingRegularFile(root, relative));
265
+ // The apps have no package.json of their own: the app root's one manifest declares their dependencies, and it is a consumer
266
+ // when it declares the Grammar package; so is every workspace package that does.
267
+ const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE)));
268
+ const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
269
+ const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
255
270
  for (const workspace of workspaces) {
256
271
  if (!workspace.startsWith('packages/')) continue;
257
272
  const manifest = `${workspace}/package.json`;
258
273
  const pkg = readJson(path.join(root, ...manifest.split('/')));
259
- if (pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE))) consumerManifests.push(manifest);
274
+ if (declaresGrammar(pkg)) consumerManifests.push(manifest);
260
275
  }
261
276
  if (!consumerManifests.length) return null;
262
277
  return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react'] };
@@ -272,10 +287,14 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
272
287
  if (!fs.lstatSync(root).isDirectory()) throw Error('Repository root must be a directory.');
273
288
  const opened = hfs ?? openHfs({ repoRoot: root });
274
289
  const { profile, apps } = opened.repo;
275
- const workspaces = workspaceDirectories(root);
276
- const inferred = inferredLayout(root, [...new Set([...workspaces, ...apps.map(app => `apps/${app.name}`)])].sort());
290
+ // A side of an app (the side folder is the root the machine judges) keeps its dependencies in the app root's one package.json.
291
+ const side = opened.repo.side ?? null;
292
+ const packageRoot = side === null ? root : path.dirname(root);
293
+ const workspaces = workspaceDirectories(root, { packageRoot, side });
294
+ const appDirs = apps.map(app => `apps/${app.name}`);
295
+ const inferred = inferredLayout(root, [...new Set([...workspaces, ...appDirs])].sort());
277
296
  const kinds = [profile === 'be' ? 'backend' : 'frontend'];
278
- const projects = discoveredProjects(root, workspaces);
297
+ const projects = discoveredProjects(root, [...new Set([...workspaces, ...appDirs])].sort());
279
298
  if (!projects.length) throw Error('The repository has no tsconfig.json to derive a TypeScript project from.');
280
299
  const backend = {
281
300
  modules: ['src/modules'],
@@ -290,11 +309,12 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
290
309
  hooks: inferred.hooks,
291
310
  modules: inferred.modules,
292
311
  transport: inferred.transport,
293
- grammar: profile === 'fe' ? derivedGrammar(root, apps, workspaces) : null,
312
+ grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps) : null,
294
313
  };
295
314
  if (profile === 'fe') assertFrontendRolesDisjoint(root, frontend);
296
315
  return {
297
316
  root,
317
+ packageRoot,
298
318
  repository: enclosingRepository(root),
299
319
  kinds,
300
320
  projects,
@@ -276,7 +276,7 @@ function contractTypeStatusFromType(ts, checker, type, seen = new Set(), depth =
276
276
  }
277
277
  for (const anonymous of ['Record', 'Partial', 'Pick', 'Omit', 'Required']) if (builtinSymbol(ts, named, anonymous)) return 'inline';
278
278
  if (type.aliasSymbol) return 'named';
279
- if (type.symbol?.getDeclarations?.().some(declaration => ts.isEnumDeclaration(declaration))) return 'named';
279
+ if ((type.symbol?.getDeclarations?.() ?? []).some(declaration => ts.isEnumDeclaration(declaration))) return 'named';
280
280
  if (type.flags & ts.TypeFlags.TypeParameter) return 'named';
281
281
  if (type.flags & ts.TypeFlags.Boolean) return 'scalar';
282
282
  if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) return 'inline';
@@ -590,7 +590,7 @@ function checkMessageReadonly(config, context, checker, declaration, localFiles,
590
590
  }
591
591
  const type = classInstanceType(ts, checker, selected);
592
592
  for (const base of type && checker.getBaseTypes ? checker.getBaseTypes(type) : []) {
593
- const declarations = base.symbol?.getDeclarations?.().filter(item => ts.isClassDeclaration(item)) ?? [];
593
+ const declarations = (base.symbol?.getDeclarations?.() ?? []).filter(item => ts.isClassDeclaration(item)) ?? [];
594
594
  if (declarations.length !== 1) {
595
595
  reasons.push(`${relativePath(config.root, sourceFile.fileName)} inherits message fields from a class outside the checked production program`);
596
596
  } else if (localFiles.has(canonical(declarations[0].getSourceFile().fileName))) visitClass(declarations[0]);
@@ -654,7 +654,7 @@ function checkInjectedClass(config, context, checker, declaration, classKind, ta
654
654
  if (classKind) {
655
655
  const type = classInstanceType(ts, checker, declaration);
656
656
  for (const base of type && checker.getBaseTypes ? checker.getBaseTypes(type) : []) {
657
- const declarations = base.symbol?.getDeclarations?.().filter(item => ts.isClassDeclaration(item)) ?? [];
657
+ const declarations = (base.symbol?.getDeclarations?.() ?? []).filter(item => ts.isClassDeclaration(item)) ?? [];
658
658
  if (declarations.length !== 1) {
659
659
  reasons.push(`${relativePath(config.root, sourceFile.fileName)} inherits an injected constructor from a class outside the checked production program`);
660
660
  } else if (localFiles.has(canonical(declarations[0].getSourceFile().fileName))) {
@@ -709,7 +709,7 @@ export function checkBackendContracts(config, context) {
709
709
  publicReasons.push(`${owner.entry} uses export =, so named capability API discovery is unavailable`);
710
710
  }
711
711
  for (const symbol of exportedSymbols(ts, checker, entry)) {
712
- const declarations = symbol.getDeclarations?.().filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName))) ?? [];
712
+ const declarations = (symbol.getDeclarations?.() ?? []).filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName))) ?? [];
713
713
  if (!declarations.length) continue;
714
714
  const representative = declarations[0];
715
715
  if (isFrameworkHelper(representative.getSourceFile(), representative, framework)) continue;
@@ -1,14 +1,17 @@
1
1
  import { treeOf } from './required-files.mjs';
2
2
  import { allowsFile } from '../../lib/hfs-allows.mjs';
3
+ import { isFeTestPath } from '../../lib/hfs-rules/fe-no-tests.mjs';
3
4
 
4
5
  /**
5
6
  * R94 `fe-slot-allows` (FE_SLOT_FILE_ROLE). A front-end slot that owns a whole directory (`fe.route`, `fe.feature`,
6
7
  * `fe.components`, `fe.hooks`, `fe.modules.api`) also says what its instances hold (`allows` in knowledge/hfs/slots.yaml).
7
8
  * Every tracked file of such a slot is matched against the `requires` and `allows` entries of its slot; a file no entry names is a
8
- * finding - `page.tsx` in a hooks domain, a `[lang]` segment under `app/`, a `.test.tsx` beside a component, a stray file at
9
- * a feature root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
9
+ * finding - `page.tsx` in a hooks domain, a `[lang]` segment under `app/`, a stray file beside a component or at a feature
10
+ * root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
10
11
  * Files no slot owns are HFS_PATH_NO_SLOT's (`hfs check`), so together every front-end file is placed by exactly one slot and
11
- * named by it.
12
+ * named by it. A test path (a `.spec.tsx` or `.test.tsx` beside a component, a test directory or test tooling) is FE_NO_TESTS's
13
+ * (R97, contract-change fe-no-tests): that rule is the one finding of such a file, so this check leaves it alone, as
14
+ * HFS_PATH_NO_SLOT does (scripts/lib/hfs-path-findings.mjs).
12
15
  */
13
16
  export const FE_SLOT_ALLOWS_RULE_IDS = ['FE_SLOT_FILE_ROLE'];
14
17
 
@@ -21,6 +24,7 @@ export function checkFeSlotAllows({ config, graph }) {
21
24
  let files = 0;
22
25
  const report = (file, message, extra = {}) => violations.push({ ruleId: RULE, path: file, line: 1, column: 1, message, ...extra });
23
26
  for (const file of [...tree.files].sort()) {
27
+ if (isFeTestPath(file)) continue; // FE_NO_TESTS's, the one finding of that file
24
28
  const classified = resolver.classifyPath(file);
25
29
  if (classified.status !== 'owned') continue;
26
30
  const slot = resolver.slot(classified.slot);
@@ -1,14 +1,11 @@
1
1
  // framework-pinned.mjs - the Next.js files that only load from a source root (the directory holding
2
- // app/), and the export names the framework mandates in them. Authored once in
3
- // knowledge/patterns/fe/folder.yaml FE-FOLDER-1 (frameworkPinnedRootFiles, frameworkPinnedRootExports),
4
- // never hard-coded here (supervisor rulings, nivo wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4 and
5
- // inc-846867b9a34e). Read by the architecture check (FE_SOURCE_LAYOUT_INVALID) and the Next name-shape
6
- // check (FE_SOURCE_NAME_SHAPE).
2
+ // app/). Authored once in knowledge/patterns/fe/folder.yaml FE-FOLDER-1 (frameworkPinnedRootFiles), never
3
+ // hard-coded here (supervisor ruling, nivo wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4). Read by the architecture
4
+ // check (FE_SOURCE_LAYOUT_INVALID).
7
5
  //
8
- // An unreadable or malformed list is a broken install, not "no pinned files": both readers throw
6
+ // An unreadable or malformed list is a broken install, not "no pinned files": the reader throws
9
7
  // ARCH_KNOWLEDGE_UNAVAILABLE so the caller reports an error instead of judging with a different contract.
10
8
  import fs from 'node:fs';
11
- import path from 'node:path';
12
9
  import { fileURLToPath } from 'node:url';
13
10
  import { parseYaml } from '../../../engine/yaml.mjs';
14
11
 
@@ -21,11 +18,6 @@ function unavailable(file, detail) {
21
18
  return Error(`ARCH_KNOWLEDGE_UNAVAILABLE: ${file} ${detail}`);
22
19
  }
23
20
 
24
- /** The file stem Next.js keys a pinned file by: `middleware.ts` -> `middleware`, `next-env.d.ts` -> `next-env`. */
25
- export function pinnedStem(fileName) {
26
- return path.basename(fileName).replace(/(?:\.d)?\.[cm]?[jt]sx?$/i, '');
27
- }
28
-
29
21
  function load(file) {
30
22
  if (cache.has(file)) return cache.get(file);
31
23
  let rule;
@@ -38,21 +30,7 @@ function load(file) {
38
30
  if (!Array.isArray(list) || !list.length || list.some(name => typeof name !== 'string' || !EXACT_NAME.test(name))) {
39
31
  throw unavailable(file, 'FE-FOLDER-1 frameworkPinnedRootFiles must be a nonempty list of exact file names.');
40
32
  }
41
- const files = new Set(list);
42
- const stems = new Set(list.map(pinnedStem));
43
- const declared = rule.frameworkPinnedRootExports;
44
- if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
45
- throw unavailable(file, 'FE-FOLDER-1 frameworkPinnedRootExports must map a pinned file stem to its framework-mandated export names.');
46
- }
47
- const exports = new Map();
48
- for (const [stem, names] of Object.entries(declared)) {
49
- if (!stems.has(stem)) throw unavailable(file, `FE-FOLDER-1 frameworkPinnedRootExports names ${stem}, which no frameworkPinnedRootFiles entry pins.`);
50
- if (!Array.isArray(names) || !names.length || names.some(name => typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name))) {
51
- throw unavailable(file, `FE-FOLDER-1 frameworkPinnedRootExports.${stem} must be a nonempty list of export identifiers.`);
52
- }
53
- exports.set(stem, new Set(names));
54
- }
55
- const loaded = { files, exports };
33
+ const loaded = { files: new Set(list) };
56
34
  if (file === FRAMEWORK_PINNED_KNOWLEDGE) cache.set(file, loaded);
57
35
  return loaded;
58
36
  }
@@ -61,23 +39,3 @@ function load(file) {
61
39
  export function frameworkPinnedRootFiles(file = FRAMEWORK_PINNED_KNOWLEDGE) {
62
40
  return load(file).files;
63
41
  }
64
-
65
- /** Map of pinned file stem to the export names the framework mandates there. */
66
- export function frameworkPinnedRootExports(file = FRAMEWORK_PINNED_KNOWLEDGE) {
67
- return load(file).exports;
68
- }
69
-
70
- /**
71
- * The framework-mandated export names for an absolute source file when it is a pinned file sitting
72
- * directly in a Next source root (its directory holds an app/ directory), else null.
73
- */
74
- export function frameworkMandatedExports(absoluteFile, file = FRAMEWORK_PINNED_KNOWLEDGE) {
75
- const { files, exports } = load(file);
76
- if (!files.has(path.basename(absoluteFile))) return null;
77
- try {
78
- if (!fs.statSync(path.join(path.dirname(absoluteFile), 'app')).isDirectory()) return null;
79
- } catch {
80
- return null;
81
- }
82
- return exports.get(pinnedStem(absoluteFile)) ?? new Set();
83
- }
@@ -108,13 +108,15 @@ function checkGrammar(config, context) {
108
108
  // Node resolution per consumer: in an npm-workspaces monorepo a consumer whose range differs from the
109
109
  // hoisted copy gets its own apps/<app>/node_modules/<package> (nivo-fe: apps/app on 0.5.0 beside a hoisted
110
110
  // 0.4.11). Every consumer is judged against the copy it actually resolves, never the hoisted one alone.
111
+ // An app installs once, at its root (config.packageRoot): a side folder has no node_modules of its own.
112
+ const installRoot = config.packageRoot ?? config.root;
111
113
  const installedFrom = (from) => {
112
- for (let dir = from; isInside(config.root, dir); dir = path.dirname(dir)) {
114
+ for (let dir = from; isInside(installRoot, dir); dir = path.dirname(dir)) {
113
115
  const candidate = path.join(dir, 'node_modules', ...grammar.package.split('/'));
114
116
  if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
115
- if (dir === config.root || path.dirname(dir) === dir) break;
117
+ if (dir === installRoot || path.dirname(dir) === dir) break;
116
118
  }
117
- return path.join(config.root, 'node_modules', ...grammar.package.split('/'));
119
+ return path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
118
120
  };
119
121
  const consumers = grammar.consumerManifests.map(relative => {
120
122
  const root = path.dirname(path.join(config.root, ...relative.split('/')));
@@ -123,7 +125,7 @@ function checkGrammar(config, context) {
123
125
  manifest: readJson(path.join(config.root, ...relative.split('/'))) ?? {} };
124
126
  });
125
127
  const installs = [...new Map(consumers.map(consumer => [consumer.packageRoot, consumer])).values()];
126
- const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(config.root, 'node_modules', ...grammar.package.split('/'));
128
+ const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
127
129
  const manifestFile = path.join(packageRoot, 'package.json');
128
130
  const manifest = readJson(manifestFile);
129
131
  const ownerOf = (fileName) => consumers.filter(consumer => isInside(consumer.root, fileName))
@@ -41,10 +41,6 @@ export const HFS_RULE_IDS = [
41
41
  'HFS_WORK_IN_FE',
42
42
  ];
43
43
 
44
- const REQUIRED_COMMON = ['.gitattributes', '.github', '.gitignore', '.husky', 'hfs.json',
45
- 'eslint.config.mjs', 'package-lock.json', 'package.json', 'README.md',
46
- 'sonar-project.properties', 'tsconfig.json'];
47
- const REQUIRED_BACKEND = ['.sops.yaml', '.starcistacks', '.starciwork', 'jest.config.js', 'nest-cli.json', 'src'];
48
44
  const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
49
45
  const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
50
46
  const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
@@ -57,18 +53,21 @@ const RETIRED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
57
53
  const EXTRA_TEST_CONFIG = /(?:^|\/)(?:jest[.-][^/]*(?:config\.[cm]?[jt]s|\.json)|jest-(?:e2e|int|integration|harness)[^/]*)$/u;
58
54
  const NODE_ENTRIES_SKIPPED = new Set(['node_modules', '.git']);
59
55
 
60
- /** The slot resolver of a repository: the caller's, else the one its hfs.json declares, else the profile's slots with no app declared. */
56
+ /** The slot resolver of a side folder: the caller's, else the side view its app's hfs.json declares, else the profile's slots with no app declared. */
61
57
  function resolverOf(root, profile, given) {
62
58
  if (given) return given;
63
59
  try { return openHfs({ repoRoot: root }); } catch { /* an invalid hfs.json is HFS_DECLARATION_INVALID's finding, judged elsewhere */ }
64
- return createSlotResolver(loadSlotManifest(), { profile, apps: [], optionalSlots: [], connections: [] });
60
+ return createSlotResolver(loadSlotManifest(), { profile, side: profile, apps: [], optionalSlots: [], connections: [], reads: [] });
65
61
  }
66
62
 
67
- /** The first path segment of every slot of the profile that may exist: the repository root entries (contracts/, docs/, src/ ...). */
68
- function slotRootEntries(resolver) {
69
- const roots = new Set(['apps']);
63
+ /**
64
+ * The first path segment of every slot of the scope that may exist: the root entries of a side (contracts/, docs/, src/ ...) or of
65
+ * the app (be/, fe/, .github/ ...). `scope` is the profile whose slots count (the app resolver answers for the sides too).
66
+ */
67
+ function slotRootEntries(resolver, scope) {
68
+ const roots = new Set(scope === 'app' ? [] : ['apps']);
70
69
  for (const slot of resolver.slots()) {
71
- if (slot.presence === 'forbidden') continue;
70
+ if (slot.presence === 'forbidden' || !slot.profiles.includes(scope)) continue;
72
71
  for (const variant of braceVariants(slot.path)) {
73
72
  const first = variant.split('/')[0];
74
73
  if (first && !/[*?<%]/u.test(first)) roots.add(first);
@@ -77,6 +76,11 @@ function slotRootEntries(resolver) {
77
76
  return roots;
78
77
  }
79
78
 
79
+ /** The root entries the scope's required slots name (the first segment of every required path of the scope): hfs.json, be/, .starcistacks/ ... */
80
+ function requiredRootEntries(resolver) {
81
+ return new Set(resolver.requiredPaths().paths.filter((entry) => !entry.side).map((entry) => entry.path.split('/')[0]));
82
+ }
83
+
80
84
  /** The folders directly below src/tests/ that the be.tests.* slots declare (world, fixtures, integration, e2e, contract). */
81
85
  function slotTestChildren(resolver) {
82
86
  const children = new Set();
@@ -145,25 +149,28 @@ function fsFiles(root, relative = '') {
145
149
  return out;
146
150
  }
147
151
 
152
+ /** The tree view of a list of tracked paths (posix, root-relative): what the git view answers, for a list already in hand. */
153
+ export function trackedTreeView(tracked) {
154
+ const files = new Set(tracked);
155
+ return {
156
+ source: 'git',
157
+ top: [...new Set(tracked.map(file => file.split('/')[0]))],
158
+ children: dir => {
159
+ const prefix = `${dir}/`;
160
+ const names = new Set();
161
+ for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
162
+ return [...names];
163
+ },
164
+ hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
165
+ hasFile: file => files.has(file),
166
+ files: () => tracked,
167
+ };
168
+ }
169
+
148
170
  /** Uniform tree view: top entries plus children(dir)/hasDir/hasFile answers over posix relatives. */
149
171
  function treeView(root) {
150
172
  const tracked = gitPaths(root);
151
- if (tracked !== null) {
152
- const files = new Set(tracked);
153
- return {
154
- source: 'git',
155
- top: [...new Set(tracked.map(file => file.split('/')[0]))],
156
- children: dir => {
157
- const prefix = `${dir}/`;
158
- const names = new Set();
159
- for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
160
- return [...names];
161
- },
162
- hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
163
- hasFile: file => files.has(file),
164
- files: () => tracked,
165
- };
166
- }
173
+ if (tracked !== null) return trackedTreeView(tracked);
167
174
  return {
168
175
  source: 'fs',
169
176
  top: fsChildren(root, '.'),
@@ -211,7 +218,7 @@ function scriptCommand(name) {
211
218
  }
212
219
 
213
220
  /** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
214
- export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'be' }) {
221
+ export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'app' }) {
215
222
  const violations = [];
216
223
  const finding = (ruleId, entry, message, line = 1) => violations.push({ ruleId, path: entry, line, column: 1, message });
217
224
  for (const entry of tree.top) {
@@ -333,7 +340,11 @@ function hooksPathNotRedirected({ root, finding }) {
333
340
  return { status: 'checked' };
334
341
  }
335
342
 
336
- function e2eInAutomaticGate({ root, tree, backend, finding }) {
343
+ /**
344
+ * The app root's automatic gates never run integration, e2e or contract: the husky hooks and the root scripts they call, the unit
345
+ * scripts over the be side's jest configuration (`jestConfig`, app-relative), and the workflows that start on push or pull_request.
346
+ */
347
+ function e2eInAutomaticGate({ root, tree, jestConfig, finding }) {
337
348
  const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
338
349
  let pkg = null;
339
350
  try { pkg = JSON.parse(readText(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
@@ -358,18 +369,31 @@ function e2eInAutomaticGate({ root, tree, backend, finding }) {
358
369
  if (runsE2e(command)) finding(rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
359
370
  for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
360
371
  }
361
- // 2. Unit and coverage scripts on a jest repository select the unit project only and exclude src/tests.
362
- if (backend && tree.hasFile('jest.config.js')) {
372
+ // 2. Unit and coverage scripts over the be side's jest configuration select the unit project only and exclude src/tests.
373
+ if (tree.hasFile(jestConfig)) {
363
374
  for (const name of UNIT_RUN_SCRIPTS) {
364
375
  const command = scripts[name];
365
376
  if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
366
377
  finding(rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
367
378
  }
368
- const jestConfig = readText(root, 'jest.config.js') ?? '';
369
- if (/collectCoverageFrom/u.test(jestConfig) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestConfig))
370
- finding(rule, 'jest.config.js', 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
379
+ const jestText = readText(root, jestConfig) ?? '';
380
+ if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
381
+ finding(rule, jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
382
+ }
383
+ // 3. A workflow that starts on push or pull_request never runs e2e.
384
+ for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
385
+ const text = readText(root, file);
386
+ if (text === null) continue;
387
+ const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
388
+ if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
389
+ if (runsE2e(withoutComments(text)))
390
+ finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
371
391
  }
372
- // 3. A back end's default tsconfig excludes the world, integration, e2e and contract trees.
392
+ }
393
+
394
+ /** The be side's default tsconfig excludes the world, integration, e2e and contract trees (they are checked by src/tests/tsconfig.json). */
395
+ function testTreesOutOfDefaultProgram({ root, tree, finding }) {
396
+ const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
373
397
  const tsconfigText = readText(root, 'tsconfig.json');
374
398
  let tsconfig = null;
375
399
  try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
@@ -378,19 +402,10 @@ function e2eInAutomaticGate({ root, tree, backend, finding }) {
378
402
  const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
379
403
  const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
380
404
  const files = tree.files();
381
- if (backend && files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
405
+ if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
382
406
  (defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
383
407
  finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
384
408
  }
385
- // 4. A workflow that starts on push or pull_request never runs e2e.
386
- for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
387
- const text = readText(root, file);
388
- if (text === null) continue;
389
- const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
390
- if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
391
- if (runsE2e(withoutComments(text)))
392
- finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
393
- }
394
409
  }
395
410
 
396
411
  export function checkHfs(config) {
@@ -401,29 +416,27 @@ export function checkHfs(config) {
401
416
  const tree = treeView(config.root);
402
417
  const violations = [];
403
418
  const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
404
- const resolver = resolverOf(config.root, backend ? 'be' : 'fe', config.hfs);
405
- const presentation = checkRepoPresentation({ root: config.root, tree, profile: backend ? 'be' : 'fe' });
406
- violations.push(...presentation.violations);
407
-
408
- const allowed = slotRootEntries(resolver);
419
+ const profile = backend ? 'be' : 'fe';
420
+ const resolver = resolverOf(config.root, profile, config.hfs);
421
+ // The README, the hooks, the workflows and the scripts are the app root's (checkAppRoot); a side folder is judged as the old
422
+ // repository root it stands for, less those.
423
+ const allowed = slotRootEntries(resolver, profile);
409
424
  for (const entry of [...tree.top].sort()) {
410
425
  if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
411
426
  if (frontend && !backend) {
412
- if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'A frontend repository must not hold a .starciwork tree; Work records live in the backend repository.'); continue; }
413
- if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, 'A frontend repository must not hold .starcistacks; stack declarations live in the backend repository.'); continue; }
414
- if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'A frontend repository keeps source only under apps/<app>/src; the root src/ tree must move.'); continue; }
427
+ if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); continue; }
428
+ if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, 'The fe side must not hold .starcistacks; stack declarations live in be/.starcistacks.'); continue; }
429
+ if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); continue; }
415
430
  }
416
431
  if (frontend && !backend && (isFeTestPath(entry) || isFeTestPath(`${entry}/x`))) continue; // a test entry of a front end is FE_NO_TESTS's, the one finding of that path
417
432
  if (!allowed.has(entry)) {
418
- finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the HFS ${backend ? 'backend' : 'frontend'} allowlist.`);
433
+ finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
419
434
  }
420
435
  }
421
436
 
422
- const required = new Set(REQUIRED_COMMON);
423
- if (backend) for (const entry of REQUIRED_BACKEND) required.add(entry);
424
- for (const entry of [...required].sort()) {
425
- if (entry === 'apps' || entry === 'README.md' || entry === '.gitattributes') continue;
426
- if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${backend ? 'backend' : 'frontend'} HFS tree requires root entry ${entry}.`);
437
+ for (const entry of [...requiredRootEntries(resolver)].sort()) {
438
+ if (entry === 'apps') continue;
439
+ if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${profile} side requires root entry ${entry}.`);
427
440
  }
428
441
 
429
442
  const apps = tree.children('apps').sort();
@@ -444,8 +457,9 @@ export function checkHfs(config) {
444
457
  }
445
458
  // next-env.d.ts is generated by Next and commonly ignored by Git; it is not
446
459
  // a reliable tracked-tree input.
447
- if (frontend) for (const entry of ['package.json', 'next.config.ts', 'tsconfig.json', 'postcss.config.mjs']) {
448
- if (!tree.hasFile(`apps/${app}/${entry}`)) missing.push(entry);
460
+ // A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
461
+ if (frontend) for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
462
+ if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
449
463
  }
450
464
  if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
451
465
  }
@@ -481,15 +495,13 @@ export function checkHfs(config) {
481
495
  finding('HFS_TEST_KIND_RETIRED', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
482
496
  }
483
497
 
484
- e2eInAutomaticGate({ root: config.root, tree, backend, finding });
485
- const hooksPath = hooksPathNotRedirected({ root: config.root, finding });
498
+ if (backend) testTreesOutOfDefaultProgram({ root: config.root, tree, finding });
486
499
 
487
500
  return {
488
501
  violations,
489
502
  coverage: {
490
503
  status: 'checked',
491
504
  source: tree.source,
492
- hooksPath,
493
505
  rootEntries: tree.top.length,
494
506
  apps,
495
507
  ruleIds: [...HFS_RULE_IDS],
@@ -497,6 +509,30 @@ export function checkHfs(config) {
497
509
  };
498
510
  }
499
511
 
512
+ /**
513
+ * The HFS tree check of the app root (`resolver` is the app's): the README, the root entries the app-root slots allow and require,
514
+ * the automatic gates (hooks, the root scripts they call, the workflows) and the hooks path. `hfs check` runs it once per app; the
515
+ * machine runs checkHfs once per side folder.
516
+ */
517
+ export function checkAppRoot({ root, resolver, tree = treeView(root) }) {
518
+ const violations = [];
519
+ const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
520
+ violations.push(...checkRepoPresentation({ root, tree, profile: 'app' }).violations);
521
+ const allowed = slotRootEntries(resolver, 'app');
522
+ for (const entry of [...tree.top].sort()) {
523
+ if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
524
+ if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the slots of the app root.`);
525
+ }
526
+ for (const entry of [...requiredRootEntries(resolver)].sort()) {
527
+ if (entry === 'README.md' || entry === '.gitattributes') continue; // checkRepoPresentation reports these two
528
+ if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The app root requires root entry ${entry}.`);
529
+ }
530
+ const jestConfig = resolver.sides ? `be/${[...braceVariants(resolver.slot('be.tool-config').path)].find(file => file.startsWith('jest.config.'))}` : null;
531
+ e2eInAutomaticGate({ root, tree, jestConfig, finding });
532
+ const hooksPath = hooksPathNotRedirected({ root, finding });
533
+ return { violations, coverage: { status: 'checked', source: tree.source, hooksPath, rootEntries: tree.top.length } };
534
+ }
535
+
500
536
  /** Keep tree findings visible even when the TypeScript architecture config is invalid. */
501
537
  export function checkHfsWithoutConfig(repositoryRoot) {
502
538
  let root;
@@ -505,9 +541,11 @@ export function checkHfsWithoutConfig(repositoryRoot) {
505
541
  }
506
542
  let kinds = [];
507
543
  try {
508
- const authored = JSON.parse(fs.readFileSync(path.resolve(root, 'hfs.json'), 'utf8'));
509
- if (authored.profile === 'be') kinds = ['backend'];
510
- else if (authored.profile === 'fe') kinds = ['frontend'];
544
+ const opened = openHfs({ repoRoot: root });
545
+ // The app root is judged by checkAppRoot; a side folder by its side's tree check.
546
+ if (opened.repo.profile === 'app') return checkAppRoot({ root, resolver: opened });
547
+ if (opened.repo.profile === 'be') kinds = ['backend'];
548
+ else if (opened.repo.profile === 'fe') kinds = ['frontend'];
511
549
  } catch { /* A malformed hfs.json remains an HFS_DECLARATION_INVALID error. */ }
512
550
  if (!kinds.length) {
513
551
  try {
@@ -37,7 +37,8 @@ function relativeSource(repository, value, label) {
37
37
  }
38
38
 
39
39
  function parseContract(config) {
40
- const manifest = JSON.parse(fs.readFileSync(path.join(config.root, 'package.json'), 'utf8'));
40
+ // The contract is declared in the app's one package.json (the app root; config.root is the fe side folder).
41
+ const manifest = JSON.parse(fs.readFileSync(path.join(config.packageRoot ?? config.root, 'package.json'), 'utf8'));
41
42
  const next = manifest?.starci?.codePatterns?.next;
42
43
  const value = next?.dataLifecycle;
43
44
  if (value === undefined) return null;
@@ -711,7 +712,7 @@ export function checkFrontendDataLifecycle(config, context) {
711
712
  details: ['package.json#starci.codePatterns.next.dataLifecycle is required when production source selects SWR', ...references.unsupported] } };
712
713
  reasons.push(...references.unsupported);
713
714
  let installed;
714
- try { installed = installedSWR(config.root); } catch (error) {
715
+ try { installed = installedSWR(config.packageRoot ?? config.root); } catch (error) {
715
716
  return { violations, coverage: { status: 'unavailable', ruleIds, details: [String(error.message ?? error)] } };
716
717
  }
717
718
  if (installed.major !== contract.swr.major) reasons.push(`installed swr ${installed.version} does not match declared major ${contract.swr.major}`);
@@ -115,7 +115,7 @@ function frameworkTargets(config, context, checker, localFiles) {
115
115
 
116
116
  function classToken(ts, checker, node, localFiles) {
117
117
  const symbol = valueSymbol(ts, checker, node);
118
- const declarations = symbol?.getDeclarations?.().filter(declaration => ts.isClassDeclaration(declaration)) ?? [];
118
+ const declarations = (symbol?.getDeclarations?.() ?? []).filter(declaration => ts.isClassDeclaration(declaration)) ?? [];
119
119
  const local = declarations.filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName)));
120
120
  const keys = [...new Set(local.map(declaration => `${canonical(declaration.getSourceFile().fileName)}#${declaration.getStart(declaration.getSourceFile())}`))];
121
121
  return { key: keys.length === 1 ? keys[0] : null, external: declarations.length > 0 && local.length === 0 };