@starci/hfs 3.0.0 → 4.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 (199) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -21
  3. package/bin/hfs.mjs +59 -37
  4. package/lint/run.mjs +70 -39
  5. package/package.json +2 -2
  6. package/runtime/engine/admission.mjs +3 -3
  7. package/runtime/engine/ledger-db.mjs +2 -2
  8. package/runtime/engine/machine-db.mjs +90 -9
  9. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  10. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  12. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  13. package/runtime/knowledge/hfs/slots.yaml +193 -128
  14. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  15. package/runtime/modules/kernel/failure-codes.yaml +23 -32
  16. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  17. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  18. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  19. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  20. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  21. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  22. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  23. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  24. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  26. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  27. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  28. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  29. package/runtime/scripts/lib/hfs-check.mjs +156 -141
  30. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  31. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  32. package/runtime/scripts/lib/hfs-rules/deps.mjs +4 -2
  33. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  34. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  35. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  36. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  37. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  38. package/runtime/scripts/lib/language.mjs +11 -1
  39. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  40. package/scaffold/app.mjs +205 -0
  41. package/scaffold/service.mjs +26 -16
  42. package/sync/cli.mjs +1 -1
  43. package/sync/hygiene.mjs +11 -8
  44. package/sync/index.mjs +109 -111
  45. package/sync/managed.mjs +9 -8
  46. package/sync/sonar-key.mjs +20 -22
  47. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
  48. package/templates/app/gitignore +6 -0
  49. package/templates/app/hooks/husky/pre-commit +25 -0
  50. package/templates/app/hooks/husky/pre-push +7 -0
  51. package/templates/app/package-scripts/package.json +22 -0
  52. package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
  53. package/templates/app/skeleton/.editorconfig +15 -0
  54. package/templates/app/skeleton/.gitattributes +2 -0
  55. package/templates/app/skeleton/.nvmrc +1 -0
  56. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  57. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  58. package/templates/app/skeleton/README.md +36 -0
  59. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  60. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  61. package/templates/be/skeleton/.sops.yaml +2 -0
  62. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  63. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  64. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  65. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  66. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  67. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  68. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  69. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  70. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  71. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  72. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  73. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  76. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  77. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  78. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  79. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  80. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  81. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  82. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  83. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  84. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  85. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  86. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  87. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  88. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  89. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  90. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  91. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  95. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  96. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  97. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  98. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  99. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  100. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  101. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  102. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  103. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  107. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  108. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  109. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  110. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  111. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  112. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  113. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  114. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  118. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  119. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  120. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  121. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  122. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  123. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  124. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  125. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  126. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  127. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  128. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  129. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  130. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  131. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  132. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  133. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  134. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  135. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  136. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  137. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  138. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  139. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  140. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  141. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  142. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  143. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  147. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  148. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  149. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  150. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  151. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  152. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  153. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  154. package/sync/skeleton.mjs +0 -76
  155. package/templates/be/gitignore +0 -2
  156. package/templates/be/hooks/husky/pre-commit +0 -13
  157. package/templates/be/hooks/husky/pre-push +0 -6
  158. package/templates/be/package-scripts/package.json +0 -19
  159. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  160. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  161. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  162. package/templates/be/tool-config/prettierignore +0 -8
  163. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
  164. package/templates/fe/gitignore +0 -3
  165. package/templates/fe/hooks/husky/pre-commit +0 -16
  166. package/templates/fe/hooks/husky/pre-push +0 -5
  167. package/templates/fe/package-scripts/package.json +0 -13
  168. package/templates/fe/parts/api-client.ts +0 -44
  169. package/templates/fe/parts/api-outcome.ts +0 -7
  170. package/templates/fe/quality-config/sonar-project.properties +0 -8
  171. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  172. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  173. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  174. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  175. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  176. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  177. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  178. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  179. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  180. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  181. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  182. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  183. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  184. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  185. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  186. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  187. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  188. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  189. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  190. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  191. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  192. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  193. package/templates/fe/tool-config/prettierrc +0 -1
  194. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  195. /package/templates/{be → app}/starciwork.gitignore +0 -0
  196. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  197. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  198. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  199. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -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
 
@@ -1,9 +1,9 @@
1
- // hfs-check.mjs - the HFS repository check, behind `hfs check | init | explain` (packages/hfs) and reusable by any
2
- // runtime check. It reads three things and nothing else: the repository's hfs.json, the slot manifest
1
+ // hfs-check.mjs - the HFS app check, behind `hfs check | explain` (packages/hfs) and reusable by any
2
+ // runtime check. It reads three things and nothing else: the app's hfs.json, the slot manifest
3
3
  // (knowledge/hfs/slots.yaml through scripts/lib/hfs-slots.mjs) and the pins (knowledge/hfs/canon-pins.yaml). It never
4
4
  // writes to the repository it inspects.
5
5
  //
6
- // checkRepo() answers, for the tracked paths of one repository (git ls-files):
6
+ // checkRepo() answers, for the tracked paths of one app (git ls-files), per scope (the app root and each side folder):
7
7
  // HFS_SLOT_UNDECLARED a tracked path no slot owns (the nearest slot is named)
8
8
  // HFS_SLOT_NOT_ENABLED a tracked path in an opt-in slot the repository did not declare
9
9
  // HFS_SLOT_AMBIGUOUS two slots of equal specificity own the path (a manifest gap, reported not guessed)
@@ -26,6 +26,7 @@
26
26
  // HFS_REPO_LOCAL_CHECK (R103, hfs-rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
27
27
  // HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
28
28
  // HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
29
+ // HFS_PEER_INTEGRATION_MISSING (R111, hfs-rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
29
30
  // FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
30
31
  // HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
31
32
  // HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
@@ -45,16 +46,16 @@ import path from 'node:path';
45
46
  import { skillRoot } from '../../engine/runtime-root.mjs';
46
47
  import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture/index.mjs';
47
48
  import { parseYaml } from '../../engine/yaml.mjs';
48
- import { HFS_DECLARATION_FILE, HfsSlotsError, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
49
+ import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
49
50
  import { allowsFile } from './hfs-allows.mjs';
50
- import { isDir } from './fs-kind.mjs';
51
51
  import { gitOutput } from './git.mjs';
52
52
  import { posixPath } from './path-key.mjs';
53
53
  import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
54
54
  import { contractFindings } from './hfs-rules/contract.mjs';
55
55
  import { depFindings } from './hfs-rules/deps.mjs';
56
- import { frontendFindings } from './hfs-rules/frontend.mjs';
56
+ import { appFrontendFindings, frontendFindings } from './hfs-rules/frontend.mjs';
57
57
  import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
58
+ import { peerIntegrationFindings } from './hfs-rules/peer-integrations.mjs';
58
59
  import { pipelineFindings } from './hfs-rules/pipeline.mjs';
59
60
  import { proofCommandFindings } from './hfs-rules/proof-commands.mjs';
60
61
  import { repoLocalCheckFindings } from './hfs-rules/repo-local-checks.mjs';
@@ -62,6 +63,7 @@ import { readJson } from './hfs-rules/read.mjs';
62
63
  import { secretFindings } from './hfs-rules/secrets.mjs';
63
64
  import { pathFindings } from './hfs-path-findings.mjs';
64
65
  import { onLintSurface } from '../checks/architecture/surface.mjs';
66
+ import { checkAppRoot, trackedTreeView } from '../checks/architecture/hfs.mjs';
65
67
  import { stacksFindings } from './hfs-rules/stacks.mjs';
66
68
  import { testTopologyFindings } from './hfs-rules/test-topology.mjs';
67
69
  import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
@@ -69,11 +71,11 @@ import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
69
71
  export const CANON_PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
70
72
  export const FAILURE_CODES_FILE = 'modules/kernel/failure-codes.yaml';
71
73
  /**
72
- * The codes `hfs check` and `hfs init` report when they cannot judge (an unreadable repository, a refused declaration or
73
- * manifest, an init that cannot proceed): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
74
+ * The codes `hfs check` reports when it cannot judge (an unreadable repository, a refused declaration or manifest, a missing
75
+ * formatter): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
74
76
  */
75
77
  export const REFUSAL_CODES = Object.freeze([
76
- 'HFS_INIT_EXISTS', 'HFS_INIT_UNDETECTED', 'HFS_REPO_UNREADABLE',
78
+ 'HFS_REPO_UNREADABLE',
77
79
  'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID', 'HFS_FORMAT_TOOL_MISSING',
78
80
  ]);
79
81
  /** The codes this module emits that are not the slot loader's own: the why bundle of packages/hfs ships exactly these plus the loader's. */
@@ -82,7 +84,7 @@ export const CHECK_CODES = Object.freeze([
82
84
  'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
83
85
  'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
84
86
  'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
85
- 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
87
+ 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
86
88
  'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
87
89
  'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
88
90
  ...REFUSAL_CODES,
@@ -137,7 +139,8 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
137
139
  let pkg;
138
140
  try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
139
141
  for (const [name, pin] of Object.entries(pins)) {
140
- if (pin.side !== 'both' && pin.side !== profile) continue;
142
+ // The app root's one package.json carries the pins of both sides.
143
+ if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
141
144
  for (const section of DEP_SECTIONS) {
142
145
  const spec = pkg[section]?.[name];
143
146
  if (spec === undefined) continue;
@@ -211,35 +214,24 @@ function treeFindings({ repoRoot, resolver }) {
211
214
  return findings;
212
215
  }
213
216
 
217
+ /** A side-relative path (or null) as an app-relative one. */
218
+ const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
219
+
214
220
  /**
215
- * The check of one repository. `declaration` overrides hfs.json (a dry run over a repository that has none); `files`
216
- * overrides git ls-files (specs). `only` (a list of paths) limits the per-path checks (slot, pin, size) to those paths; the
217
- * checks of the tree as a whole (required files, minimum instances, empty directories, untracked entries) are not
218
- * per-path. `tree: false` skips the file-system checks (empty directories, ghosts, untracked); they also do not run
219
- * over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the same repository (the
220
- * managed files), judged and counted with this module's own. Returns {ok, profile, apps, manifest, tracked, findings,
221
- * counts}; a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never
222
- * an exception.
221
+ * The findings of one scope: the app root (profile app; its own files, and the rules of the files only the root holds: the one
222
+ * package.json and lockfile, CI, hooks, .starciwork) or one side (profile be or fe; the side folder is `repoRoot` and every path is
223
+ * relative to it, exactly as the standalone repository root was). `files` are the scope's tracked paths; `all` (root only) every
224
+ * tracked path of the app, for the rules that read across it (dependency skew, proof commands).
223
225
  */
224
- export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }), surface = 'all' }) {
225
- const why = readWhy(root);
226
- let repo;
227
- try {
228
- repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
229
- } catch (error) {
230
- if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
231
- const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
232
- return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
233
- }
234
- const resolver = createSlotResolver(manifest, repo);
235
- const tracked = files ?? trackedFiles(repoRoot);
236
- const trackedSet = new Set(tracked);
237
- const present = (p) => (p.endsWith('/') ? tracked.some((f) => f.startsWith(p)) : trackedSet.has(p));
238
- const findings = [];
239
- const scoped = only ? new Set(only) : null;
226
+ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, scoped }) {
240
227
  const inScope = (file) => !scoped || scoped.has(file);
228
+ // A required directory of the root (be/, fe/) is present through the files below it, which are the sides' own.
229
+ const trackedSet = new Set(all);
230
+ const present = (p) => (p.endsWith('/') ? all.some((f) => f.startsWith(p)) : trackedSet.has(p));
231
+ const findings = [];
232
+ const isRoot = repo.profile === APP_SCOPE;
241
233
 
242
- findings.push(...pathFindings({ files: tracked.filter(inScope), resolver, profile: repo.profile }));
234
+ findings.push(...pathFindings({ files: files.filter(inScope), resolver, profile: repo.profile }));
243
235
 
244
236
  const required = resolver.requiredPaths();
245
237
  const missing = new Set();
@@ -250,46 +242,104 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
250
242
  const app = appOf(p);
251
243
  findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
252
244
  };
253
- for (const entry of required.paths) missingFile(entry.slot, entry.path, entry.via);
254
- const instances = instancesOf(resolver, tracked);
245
+ // The app's own required paths; each side reports its own (the resolver of the app lists them too, with their side).
246
+ for (const entry of required.paths) if (!entry.side) missingFile(entry.slot, entry.path, entry.via);
247
+ const instances = instancesOf(resolver, files);
255
248
  for (const instance of instances) for (const p of requiredOf(resolver.slot(instance.slot), instance)) missingFile(instance.slot, p, 'requires');
256
- for (const { slot, min, appKind } of required.minimums) {
257
- if (appKind !== undefined) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
249
+ for (const { slot, min, appKind, side } of required.minimums) {
250
+ if (appKind !== undefined || side) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
258
251
  const count = instances.filter((i) => i.slot === slot).length;
259
252
  if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
260
253
  }
261
254
 
262
255
  const pins = readPins(root);
263
- findings.push(...pinFindings({ repoRoot, files: tracked, profile: repo.profile, pins, only: scoped }));
256
+ findings.push(...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }));
264
257
 
265
- // The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-repository, cheap, no tool run.
266
- const secrets = secretFindings({ repoRoot, files: tracked.filter(inScope), resolver });
267
- const declared = declaration === undefined ? readJson(repoRoot, 'hfs.json') : declaration;
258
+ // The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-scope, cheap, no tool run.
268
259
  findings.push(
269
- ...secrets,
270
- ...depFindings({ repoRoot, files: tracked }),
271
- ...pipelineFindings({ repoRoot, files: tracked, pins }),
272
- ...contractFindings({ repoRoot, files: tracked, repo, resolver, stacks: declared?.stacks }),
273
- ...repoLocalCheckFindings({ repoRoot, files: tracked }),
274
- ...lintSuppressionFindings({ repoRoot, files: tracked }),
275
- ...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
276
- ...extraFindings,
260
+ ...secretFindings({ repoRoot, files: files.filter(inScope), resolver }),
261
+ ...repoLocalCheckFindings({ repoRoot, files }),
262
+ ...lintSuppressionFindings({ repoRoot, files }),
277
263
  );
264
+ if (isRoot) {
265
+ findings.push(
266
+ ...depFindings({ repoRoot, files: all }),
267
+ ...peerIntegrationFindings({ repoRoot, files: all }),
268
+ ...pipelineFindings({ repoRoot, files, pins }),
269
+ ...testTopologyFindings({ repoRoot, files }),
270
+ ...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
271
+ ...appFrontendFindings({ repoRoot, files: all, repo }),
272
+ // The tree check of the app root the machine runs per side for a side folder: README, root entries, automatic gates, hooks path.
273
+ ...checkAppRoot({ root: repoRoot, resolver, tree: trackedTreeView(all) }).violations.map((item) => ({ code: item.ruleId, level: 'error', path: item.path, line: item.line, column: item.column, source: 'machine', message: `${item.path}: ${item.message}` })),
274
+ );
275
+ } else if (repo.profile === 'be') {
276
+ findings.push(...contractFindings({ files, repo, resolver }), ...stacksFindings({ repoRoot, files, resolver }), ...testTopologyFindings({ repoRoot, files }));
277
+ } else {
278
+ findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter(inScope) }));
279
+ }
278
280
 
279
- const soft = resolver.ruleParams().fileLines.soft;
280
- for (const file of tracked) {
281
- if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
282
- let lines;
283
- try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
284
- if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
281
+ if (!isRoot) {
282
+ const soft = resolver.ruleParams().fileLines.soft;
283
+ for (const file of files) {
284
+ if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
285
+ let lines;
286
+ try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
287
+ if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
288
+ }
285
289
  }
290
+ return findings;
291
+ }
286
292
 
293
+ /**
294
+ * The check of one app. `repoRoot` is the app root (a side folder is checked as that side alone). `declaration` overrides hfs.json
295
+ * (a dry run over an app that has none); `files` overrides git ls-files (specs), app-relative. `only` (a list of app-relative paths)
296
+ * limits the per-path checks (slot, pin, size) to those paths; the checks of the tree as a whole (required files, minimum instances,
297
+ * empty directories, untracked entries) are not per-path. `tree: false` skips the file-system checks (empty directories, ghosts,
298
+ * untracked); they also do not run over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the
299
+ * same app (the managed files, the formatter, the contract emit), app-relative, judged and counted with this module's own.
300
+ * The root scope and each side run their own rules over their own files (hfs checks per side, the side folder as the old repository
301
+ * root, plus the root checks); every finding path is app-relative. Returns {ok, profile, apps, manifest, tracked, findings, counts};
302
+ * a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never an exception.
303
+ */
304
+ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }), surface = 'all' }) {
305
+ const why = readWhy(root);
306
+ let repo;
307
+ try {
308
+ repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
309
+ } catch (error) {
310
+ if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
311
+ const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
312
+ return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
313
+ }
314
+ const resolver = createSlotResolver(manifest, repo);
315
+ const tracked = files ?? trackedFiles(repoRoot);
316
+ const scoped = only ? new Set(only) : null;
317
+ const findings = [];
318
+ // `hfs check` leaves to the lint canon the per-path findings that sit on an existing TypeScript file (hfs-path-findings.mjs).
319
+ const keep = (list, scopeRoot) => (surface === 'check' ? list.filter((finding) => !(finding.origin === 'repo' && onLintSurface(scopeRoot, finding))) : list);
320
+ if (repo.profile === APP_SCOPE) {
321
+ const own = tracked.filter((file) => resolver.sideOf(file) === null);
322
+ findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: own, all: tracked, scoped }), repoRoot));
323
+ for (const side of SIDES) {
324
+ const prefix = `${side}/`;
325
+ const sideRoot = path.join(repoRoot, side);
326
+ const sideFiles = tracked.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length));
327
+ const sideScoped = scoped ? new Set([...scoped].filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length))) : null;
328
+ const sideFindings = scopeFindings({ repoRoot: sideRoot, root, repo: repo.sides[side], resolver: resolver.sides[side], files: sideFiles, scoped: sideScoped });
329
+ const message = appRelativeMessages(side, sideRoot);
330
+ findings.push(...keep(sideFindings, sideRoot).map((finding) => ({ ...finding, side, path: onSide(side, finding.path), message: message(finding.message) })));
331
+ }
332
+ } else {
333
+ findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: tracked, scoped }), repoRoot));
334
+ }
335
+ findings.push(...extraFindings);
287
336
  if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
288
337
 
289
- // `hfs check` leaves to the lint canon the per-path findings that sit on an existing TypeScript file (hfs-path-findings.mjs).
290
- const finished = withWhy(surface === 'check' ? findings.filter((finding) => !(finding.origin === 'repo' && onLintSurface(repoRoot, finding))) : findings, why);
338
+ // The app-root tree check reports machine codes: their why is read with the check's own.
339
+ const finished = withWhy(findings, { ...why, ...readWhy(root, [...new Set(findings.map((f) => f.code).filter((code) => !why[code]))]) });
291
340
  const counts = summarize(finished);
292
- return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps: repo.apps, tracked: tracked.length, findings: finished, counts };
341
+ const apps = repo.profile === APP_SCOPE ? SIDES.flatMap((side) => repo.sides[side].apps.map((app) => ({ ...app, side }))) : repo.apps;
342
+ return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps, tracked: tracked.length, findings: finished, counts };
293
343
  }
294
344
 
295
345
  // ------------------------------------------------------------------------------------------ the whole check
@@ -326,45 +376,66 @@ function machineFindings(report) {
326
376
  }
327
377
 
328
378
  /**
329
- * The whole `hfs check` of one repository: checkRepo() (slots, pins, size, tree) and then the architecture machine over the
330
- * same work tree, its violations and errors merged in as findings with the Vietnamese why of their codes. `fast` judges
331
- * only what changed since the merge-base (`base` names another ref): the per-path slot and pin checks on the changed
332
- * paths, the machine on the owners of the changed source files without clones and dead exports, and no file-system tree
333
- * checks. `extraFindings` are the findings of the emitters that render templates or run the repository's formatter
334
- * (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing merge-base under `fast` is an Error, never a silent full pass.
379
+ * The whole `hfs check` of one app: checkRepo() (the root and both sides: slots, pins, size, tree) and then the architecture machine
380
+ * over each side folder (the side as the repository root it judges), its violations and errors merged in as findings with the
381
+ * Vietnamese why of their codes and the side prefixed onto their paths. `fast` judges only what changed since the merge-base (`base`
382
+ * names another ref): the per-path slot and pin checks on the changed paths, the machine on the owners of the changed source files
383
+ * without clones and dead exports, and no file-system tree checks. `extraFindings` are the findings of the emitters that render
384
+ * templates or run the formatter (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing
385
+ * merge-base under `fast` is an Error, never a silent full pass. `repoRoot` may also be a side folder: that side alone is checked.
335
386
  */
336
387
  export function checkRepository({ repoRoot, root = skillRoot, fast = false, base, extraFindings = [], manifest = loadSlotManifest({ root }), machine = checkArchitecture }) {
337
388
  const changed = fast ? changedSince(repoRoot, base) : null;
338
- const baseSha = changed?.base;
339
389
  const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, surface: 'check', ...(changed ? { only: changed.files, tree: false } : {}) });
340
390
  if (slotResult.profile === null) return { ...slotResult, machine: { status: 'skipped', reason: 'hfs.json is not valid' } };
341
-
342
- let paths;
343
- if (changed) {
344
- const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, repoRoot));
345
- paths = [...new Set(changed.files.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
346
- if (!paths.length) return { ...slotResult, machine: { status: 'skipped', reason: 'no changed source file', base: changed.base }, fast: { base: changed.base, changed: changed.files.length } };
347
- }
348
- let report;
349
- try {
350
- report = machine({ repositoryRoot: repoRoot, base: baseSha, surface: 'check', ...(changed ? { paths, fast: true } : {}) });
351
- } catch (error) {
352
- report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
391
+ const repo = readRepoDeclaration(manifest, repoRoot);
392
+ const scopes = repo.profile === APP_SCOPE
393
+ ? SIDES.map((side) => ({ side, repoRoot: path.join(repoRoot, side), prefix: `${side}/` }))
394
+ : [{ side: null, repoRoot, prefix: '' }];
395
+ const runs = [];
396
+ const found = [];
397
+ for (const scope of scopes) {
398
+ const run = machineOver({ scope, manifest, machine, changed });
399
+ runs.push(run.info);
400
+ found.push(...run.findings);
353
401
  }
354
- const found = machineFindings(report);
355
402
  const why = readWhy(root, [...new Set(found.map((f) => f.code))]);
356
403
  const findings = [...slotResult.findings, ...withWhy(found, why)];
357
404
  const counts = summarize(findings);
405
+ const ran = runs.filter((info) => info.status === 'ran');
358
406
  return {
359
407
  ...slotResult,
360
408
  ok: counts.error === 0,
361
409
  findings,
362
410
  counts,
363
- machine: { status: 'ran', files: report.files, kinds: report.kinds, ...(changed ? { paths, base: changed.base } : {}) },
411
+ machine: ran.length
412
+ ? { status: 'ran', files: ran.reduce((sum, info) => sum + info.files, 0), kinds: [...new Set(ran.flatMap((info) => info.kinds ?? []))], sides: runs, ...(changed ? { paths: ran.flatMap((info) => info.paths ?? []), base: changed.base } : {}) }
413
+ : { status: 'skipped', reason: runs.map((info) => info.reason).join('; '), sides: runs, ...(changed ? { base: changed.base } : {}) },
364
414
  ...(changed ? { fast: { base: changed.base, changed: changed.files.length } } : {}),
365
415
  };
366
416
  }
367
417
 
418
+ /** The architecture machine over one scope (a side folder): `{ info, findings }`, finding paths app-relative. */
419
+ function machineOver({ scope, manifest, machine, changed }) {
420
+ const { side, prefix } = scope;
421
+ const label = side ?? 'repository';
422
+ let paths;
423
+ if (changed) {
424
+ const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, scope.repoRoot));
425
+ const mine = changed.files.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
426
+ paths = [...new Set(mine.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
427
+ if (!paths.length) return { info: { side, status: 'skipped', reason: `no changed source file in ${label}` }, findings: [] };
428
+ }
429
+ let report;
430
+ try {
431
+ report = machine({ repositoryRoot: scope.repoRoot, base: changed?.base, surface: 'check', ...(changed ? { paths, fast: true } : {}) });
432
+ } catch (error) {
433
+ report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
434
+ }
435
+ const message = side ? appRelativeMessages(side, scope.repoRoot) : null;
436
+ const findings = machineFindings(report).map((finding) => (side ? { ...finding, side, message: message(finding.message), ...(finding.path ? { path: onSide(side, finding.path) } : {}) } : finding));
437
+ return { info: { side, status: 'ran', files: report.files, kinds: report.kinds, ...(paths ? { paths: paths.map((p) => `${prefix}${p}`) } : {}) }, findings };
438
+ }
368
439
  // --------------------------------------------------------------------------------------------------- explain
369
440
 
370
441
  const TEST_KIND = {
@@ -386,7 +457,9 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
386
457
  const slot = resolver.slot(c.slot);
387
458
  const tier = resolver.tierOf(c.path);
388
459
  const owner = resolver.ownerOf(c.path);
389
- const mayImport = tier && tier !== 'none' ? resolver.allowedImports(tier) : null;
460
+ // A path of a side imports by its side's direction matrix; the app root has none of its own.
461
+ const scope = c.side ? resolver.sides[c.side] : resolver;
462
+ const mayImport = tier && tier !== 'none' ? scope.allowedImports(tier) : null;
390
463
  return {
391
464
  path: c.path,
392
465
  status: c.status,
@@ -407,61 +480,3 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
407
480
  ...(c.status === 'not-enabled' ? { code: 'HFS_SLOT_NOT_ENABLED', titleVi: why.HFS_SLOT_NOT_ENABLED.titleVi, whyVi: why.HFS_SLOT_NOT_ENABLED.whyVi } : {}),
408
481
  };
409
482
  }
410
-
411
- // ------------------------------------------------------------------------------------------------- init
412
-
413
- const SLUG_SUFFIX = /-(backend|be|frontend|fe|api|web|app)$/;
414
- const exists = (p) => fs.existsSync(p);
415
- const readPackage = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } };
416
- const depsOf = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
417
-
418
- /**
419
- * A starter hfs.json by detection: profile from the dependencies (next -> fe, @nestjs/core -> be, over the root and
420
- * every apps/<name>), apps from the apps/<name> directories (fe: kind next; be: worker/migrate/cli by name, else api).
421
- * optionalSlots are the opt-in slots (not implied by an app kind) that tracked files already occupy. Connections are not guessed; a repository that keeps a database declares them by hand and gains the migrate app.
422
- * A repository the detection cannot classify is HFS_INIT_UNDETECTED, never a guess.
423
- */
424
- export function detectDeclaration({ repoRoot, manifest }) {
425
- const appsDir = path.join(repoRoot, 'apps');
426
- const names = isDir(appsDir) ? fs.readdirSync(appsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules').map((e) => e.name).sort() : [];
427
- const rootPkg = readPackage(repoRoot);
428
- const all = { ...depsOf(rootPkg) };
429
- for (const name of names) Object.assign(all, depsOf(readPackage(path.join(appsDir, name))));
430
- const isFe = 'next' in all || names.some((n) => ['next.config.ts', 'next.config.js', 'next.config.mjs'].some((f) => exists(path.join(appsDir, n, f))));
431
- const isBe = '@nestjs/core' in all || exists(path.join(repoRoot, 'nest-cli.json'));
432
- if (isFe === isBe) refuse('HFS_INIT_UNDETECTED', `cannot tell the profile of ${repoRoot}: ${isFe ? 'both next and Nest are present' : 'neither next nor @nestjs/core is declared'}`, { repoRoot });
433
- const profile = isFe ? 'fe' : 'be';
434
- const apps = names.map((name) => {
435
- if (profile === 'fe') return { name, kind: 'next' };
436
- if (!isDir(path.join(appsDir, name, 'src'))) return null;
437
- const kind = /migrat/.test(name) ? 'migrate' : (/worker/.test(name) ? 'worker' : (/(^|-)cli($|-)/.test(name) ? 'cli' : 'api'));
438
- return { name, kind };
439
- }).filter(Boolean).filter((a) => manifest.appKinds[profile].includes(a.kind));
440
- if (!apps.length) refuse('HFS_INIT_UNDETECTED', `${repoRoot} has no apps/<name> ${profile === 'fe' ? 'Next application' : 'Nest application with a src directory'} to declare`, { repoRoot, profile });
441
- const project = String(rootPkg?.name ?? path.basename(repoRoot)).replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(SLUG_SUFFIX, '');
442
- const declaration = { hfs: manifest.major, profile, project: /^[a-z]/.test(project) ? project : `p-${project}`, apps };
443
- const optionalSlots = occupiedOptInSlots({ repoRoot, manifest, declaration });
444
- return optionalSlots.length ? { ...declaration, optionalSlots } : declaration;
445
- }
446
-
447
- /** The opt-in slots (not enabled by an app kind) that at least one tracked file falls in, found by enabling them all for a look. */
448
- function occupiedOptInSlots({ repoRoot, manifest, declaration }) {
449
- let files;
450
- try { files = trackedFiles(repoRoot); } catch (error) { if (error.code === 'HFS_REPO_UNREADABLE') return []; throw error; }
451
- const optIn = manifest.slots.filter((s) => s.profiles.includes(declaration.profile) && s.presence === 'opt-in' && s.appKind === undefined).map((s) => s.id);
452
- const everything = createSlotResolver(manifest, resolveRepoDeclaration(manifest, { ...declaration, optionalSlots: optIn }));
453
- const used = new Set();
454
- for (const file of files) { const c = everything.classifyPath(file); if (c.status === 'owned' && optIn.includes(c.slot)) used.add(c.slot); }
455
- return optIn.filter((id) => used.has(id));
456
- }
457
-
458
- /** Write hfs.json unless one exists (HFS_INIT_EXISTS); `write: false` returns the text only. */
459
- export function initRepo({ repoRoot, root = skillRoot, write = true, manifest = loadSlotManifest({ root }) }) {
460
- const file = path.join(repoRoot, HFS_DECLARATION_FILE);
461
- if (write && exists(file)) refuse('HFS_INIT_EXISTS', `${file} already exists; init never rewrites a declaration`, { file });
462
- const declaration = detectDeclaration({ repoRoot, manifest });
463
- resolveRepoDeclaration(manifest, declaration);
464
- const text = `${JSON.stringify(declaration, null, 2)}\n`;
465
- if (write) fs.writeFileSync(file, text);
466
- return { file, declaration, text, written: write };
467
- }
@@ -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
  }