@starci/hfs 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +116 -12
  3. package/bin/hfs.mjs +119 -15
  4. package/emit/compiler.mjs +35 -0
  5. package/emit/contracts.mjs +97 -0
  6. package/emit/operations-worker.mjs +24 -0
  7. package/emit/operations.mjs +126 -0
  8. package/emit/schema-worker.mjs +117 -0
  9. package/emit/static-graph.mjs +670 -0
  10. package/emit/type-schema.mjs +145 -0
  11. package/package.json +10 -2
  12. package/report/sonar.mjs +180 -0
  13. package/runtime/engine/admission.mjs +284 -0
  14. package/runtime/engine/digest.mjs +10 -0
  15. package/runtime/engine/ledger-db.mjs +1245 -0
  16. package/runtime/engine/machine-db.mjs +1484 -0
  17. package/runtime/engine/migrations/machine/0001-init.sql +887 -0
  18. package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
  19. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
  20. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
  21. package/runtime/engine/plain-object.mjs +5 -0
  22. package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
  23. package/runtime/knowledge/hfs/slots.yaml +409 -140
  24. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  25. package/runtime/knowledge/sonar-gate.yaml +85 -0
  26. package/runtime/modules/kernel/failure-codes.yaml +1480 -16
  27. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  28. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  29. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  30. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  31. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  32. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  33. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  34. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  35. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  36. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  37. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  38. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  39. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  40. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  41. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  42. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  43. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  44. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  45. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  46. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  47. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  48. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  49. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  50. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  51. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  52. package/runtime/scripts/checks/architecture/index.mjs +316 -0
  53. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  54. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  55. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  56. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  57. package/runtime/scripts/checks/architecture/owners.mjs +89 -0
  58. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  59. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  60. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  61. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  62. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  63. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  64. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  65. package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
  66. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  67. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  68. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  69. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  70. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  71. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  72. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  73. package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
  74. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  75. package/runtime/scripts/checks/architecture.mjs +41 -0
  76. package/runtime/scripts/checks/common.mjs +37 -0
  77. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  78. package/runtime/scripts/lib/artifact-hold.mjs +89 -0
  79. package/runtime/scripts/lib/artifact-store.mjs +103 -0
  80. package/runtime/scripts/lib/fs-kind.mjs +10 -0
  81. package/runtime/scripts/lib/git.mjs +53 -0
  82. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  83. package/runtime/scripts/lib/hfs-check.mjs +254 -28
  84. package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
  85. package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
  86. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  87. package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
  88. package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
  89. package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
  90. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
  91. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  92. package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
  93. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  94. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
  95. package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
  96. package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
  97. package/runtime/scripts/lib/hfs-slots.mjs +95 -41
  98. package/runtime/scripts/lib/hfs-tree.mjs +80 -0
  99. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  100. package/runtime/scripts/lib/json.mjs +22 -0
  101. package/runtime/scripts/lib/language.mjs +107 -0
  102. package/runtime/scripts/lib/path-key.mjs +2 -0
  103. package/runtime/scripts/lib/redact.mjs +148 -0
  104. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  105. package/runtime/scripts/lib/safe-remove.mjs +179 -0
  106. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  107. package/runtime/scripts/lib/sleep-sync.mjs +17 -0
  108. package/runtime/scripts/lib/stack-declaration.mjs +52 -0
  109. package/runtime/scripts/lib/stack-services.mjs +217 -0
  110. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  111. package/scaffold/service.mjs +333 -0
  112. package/sync/format.mjs +46 -0
  113. package/sync/hygiene.mjs +56 -24
  114. package/sync/index.mjs +126 -41
  115. package/sync/managed.mjs +170 -0
  116. package/sync/skeleton.mjs +32 -10
  117. package/sync/sonar-key.mjs +13 -0
  118. package/sync/ts-strict.mjs +48 -0
  119. package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
  120. package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
  121. package/templates/be/hooks/husky/pre-commit +13 -0
  122. package/templates/be/hooks/husky/pre-push +7 -0
  123. package/templates/be/package-scripts/package.json +21 -0
  124. package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
  125. package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
  126. package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
  127. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  128. package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
  129. package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
  130. package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
  131. package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
  132. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
  133. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
  134. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
  135. package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
  136. package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
  137. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
  138. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
  139. package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
  140. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
  141. package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
  142. package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
  143. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
  144. package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
  145. package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
  146. package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
  147. package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
  148. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
  149. package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
  150. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
  151. package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
  152. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
  153. package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
  154. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
  155. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
  156. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
  157. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
  158. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
  159. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
  162. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
  163. package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
  164. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
  165. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
  166. package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
  167. package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
  168. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
  169. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
  170. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
  171. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
  172. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
  173. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
  174. package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
  175. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
  176. package/templates/be/tool-config/eslint.config.mjs +3 -0
  177. package/templates/be/tool-config/jest.config.js +1 -0
  178. package/templates/be/tool-config/prettierignore +8 -0
  179. package/templates/be/tool-config/prettierrc +1 -0
  180. package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
  181. package/templates/be/tool-config/tsconfig.build.json +5 -0
  182. package/templates/be/tool-config/tsconfig.json +11 -0
  183. package/templates/common/gitignore.base +1 -1
  184. package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
  185. package/templates/fe/hooks/husky/pre-commit +16 -0
  186. package/templates/fe/hooks/husky/pre-push +6 -0
  187. package/templates/fe/package-scripts/package.json +17 -0
  188. package/templates/fe/parts/api-client.ts +44 -0
  189. package/templates/fe/parts/api-outcome.ts +7 -0
  190. package/templates/fe/quality-config/sonar-project.properties +8 -0
  191. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
  192. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
  193. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
  194. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  195. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
  196. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
  197. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
  198. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
  199. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
  200. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
  201. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
  202. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
  203. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
  204. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
  205. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
  206. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
  207. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
  208. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
  209. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
  210. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
  211. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
  212. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
  213. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
  214. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
  215. package/templates/fe/tool-config/eslint.config.mjs +3 -0
  216. package/templates/fe/tool-config/prettierignore +10 -0
  217. package/templates/fe/tool-config/prettierrc +1 -0
  218. package/templates/fe/tool-config/stylelint.config.mjs +3 -0
  219. package/templates/fe/tool-config/tsconfig.json +4 -0
  220. package/templates/be/pre-commit +0 -8
  221. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
  222. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
  223. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
  224. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
  225. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
  226. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
  227. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
  228. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
  229. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
  230. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
  231. package/templates/common/codecov.yml +0 -13
  232. package/templates/common/pre-push +0 -5
  233. package/templates/fe/e2e.yml +0 -22
  234. package/templates/fe/pre-commit +0 -7
  235. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
  236. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
  237. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
  238. package/templates/fe/sonar-project.properties +0 -11
  239. /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
  240. /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
  241. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
  242. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
  243. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
  244. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
  245. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
  246. /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
@@ -0,0 +1,73 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * R21 `cross-app-duplicate` (FE_CROSS_APP_DUPLICATE): the slot manifest's `crossApp` law says apps never import each other and
6
+ * shared code is a `packages/<pkg>` slot. `HFS_DUPLICATE_CODE` sees blocks of a size inside the owner graph; this sees whole
7
+ * files of any size, route-tree files included: two TypeScript files under different apps whose token-normalised content is
8
+ * equal (comments and whitespace do not exist in the token sequence; identifiers and literals are kept) are one file copied
9
+ * instead of moved to a package.
10
+ *
11
+ * Exempt: a file made only of import and export statements. It is the sanctioned shape of a Next route file that must exist
12
+ * per app and re-exports one package symbol (`export { default } from "@family/ui/global-error"`), and it holds nothing to move.
13
+ * Not source, so not judged: declaration files and everything that is not .ts/.tsx (JSON catalogs, CSS).
14
+ */
15
+ export const CROSS_APP_DUPLICATE_RULE_IDS = ['FE_CROSS_APP_DUPLICATE'];
16
+
17
+ const RULE = 'FE_CROSS_APP_DUPLICATE';
18
+ const SOURCE = /\.[cm]?tsx?$/u;
19
+
20
+ export function checkCrossAppDuplicate({ graph, context }) {
21
+ const ts = context.ts;
22
+ const { SyntaxKind } = ts;
23
+ const resolver = graph.resolver;
24
+ const declaredApps = resolver.repo.apps.map(app => app.name);
25
+ /** The app a file belongs to: the slot binding when a slot owns it, else the declared app whose directory holds it. */
26
+ const appOf = rel => resolver.classifyPath(rel).bindings?.app ?? declaredApps.find(name => rel.startsWith(`apps/${name}/`)) ?? null;
27
+ const onlyModuleEdges = file => file.sourceFile.statements.every(statement => ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)
28
+ || (ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression)));
29
+
30
+ const tokens = sourceFile => {
31
+ const parts = [];
32
+ const visit = node => {
33
+ if (node.kind >= SyntaxKind.FirstJSDocNode && node.kind <= SyntaxKind.LastJSDocNode) return;
34
+ if (node.kind === SyntaxKind.JsxText && node.containsOnlyTriviaWhiteSpaces) return;
35
+ const children = node.getChildren(sourceFile);
36
+ if (!children.length) {
37
+ if (node.kind !== SyntaxKind.EndOfFileToken) parts.push(node.kind === SyntaxKind.JsxText ? node.getText(sourceFile).replace(/\s+/gu, ' ').trim() : node.getText(sourceFile));
38
+ return;
39
+ }
40
+ for (const child of children) visit(child);
41
+ };
42
+ visit(sourceFile);
43
+ return parts.join('\u0000');
44
+ };
45
+
46
+ const groups = new Map();
47
+ let files = 0;
48
+ for (const file of graph.files.values()) {
49
+ if (!SOURCE.test(file.rel) || /\.d\.[cm]?tsx?$/u.test(file.rel)) continue;
50
+ const app = appOf(file.rel);
51
+ if (!app || onlyModuleEdges(file) || !file.sourceFile.statements.length) continue;
52
+ files += 1;
53
+ const key = createHash('sha256').update(tokens(file.sourceFile)).digest('hex');
54
+ if (!groups.has(key)) groups.set(key, []);
55
+ groups.get(key).push({ rel: file.rel, app });
56
+ }
57
+
58
+ const violations = [];
59
+ let duplicated = 0;
60
+ for (const group of groups.values()) {
61
+ if (new Set(group.map(item => item.app)).size < 2) continue;
62
+ duplicated += 1;
63
+ group.sort((a, b) => a.rel.localeCompare(b.rel));
64
+ for (const item of group) {
65
+ const twins = group.filter(other => other.app !== item.app).map(other => other.rel);
66
+ violations.push({
67
+ ruleId: RULE, path: item.rel, line: 1, column: 1, twins,
68
+ message: `${item.rel} is token-for-token the same file as ${twins.join(', ')} in another app (comments and whitespace ignored); shared code is a packages/<pkg> slot. Move it to a package (packages/<pkg>) and let each app re-export it (${path.posix.basename(item.rel)} keeps only an import or export statement).`,
69
+ });
70
+ }
71
+ }
72
+ return { violations, coverage: { status: 'checked', files, duplicatedGroups: duplicated } };
73
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * HFS check 4, dead code (knowledge/hfs/rules.yaml R25, both profiles, errors in every gate):
3
+ * HFS_UNUSED_EXPORT an export of an owner's public entry that no production file outside the owner imports is dead.
4
+ * HFS_UNUSED_FILE a production source file that no root reaches is dead. Roots: the files an app slot requires
5
+ * (main.ts, app.module.ts), every route file (slot tier `route`), the public entry of every package,
6
+ * and a source file a framework config of the app names by a relative string literal
7
+ * (next.config.ts pointing next-intl at its request config). Specs and tests are not in the graph,
8
+ * so a file only a spec imports is dead, on purpose. Type-only imports reach a file.
9
+ *
10
+ * Owner = every graph.ownerRoots unit whose tier is not `app` and whose public entry file is in the graph (index.ts or
11
+ * index.tsx, a package's src/index.ts). The exported names of the entry are its named exports, `export { a as b }`,
12
+ * `export { x } from`, declarations with `export`, and `default` for `export default`. `export *` is refused by owners.mjs
13
+ * and contributes no name here.
14
+ *
15
+ * A name is used when a production file outside the owner imports it from the entry (type-only imports count). A
16
+ * namespace import, `import x = require`, a dynamic import()/require(), an `export *` or `export * as` of the entry make
17
+ * every name used. A file outside the owner that re-exports the name (`export { n as m } from`) uses it when `m` is itself
18
+ * used by the importers of that file; a re-exporting file nobody imports is a framework entry (a route file) and uses it.
19
+ * Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with one exception (unit test
20
+ * standard): a `<name>.service.spec.ts` (the only unit spec kind) and a `*.builder.ts` under src/tests/fixtures/builders read from
21
+ * disk count as consumers, because a spec can only provide an Inject*() token or a param type the entry exports. Specs of any
22
+ * other kind, e2e specs and world files still do not count. A consumer inside the owner is skipped like any inside consumer.
23
+ */
24
+ import fs from 'node:fs';
25
+ import { canonical } from './config.mjs';
26
+ import path from 'node:path';
27
+ import { treeOf } from './required-files.mjs';
28
+
29
+ export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
30
+
31
+ const PACKAGE_ENTRIES = ['src/index.ts', 'src/index.tsx', 'index.ts', 'index.tsx'];
32
+ const OWNER_ENTRIES = ['index.ts', 'index.tsx'];
33
+ const CHAIN_DEPTH = 6;
34
+ /** Tiers whose files are not production source that an app serves: judged by no dead-file rule. */
35
+ const UNJUDGED_TIERS = new Set(['none', 'e2e', 'fixtures']);
36
+ const CONFIG_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js'];
37
+ const CONFIG_STRING = /['"`](\.{1,2}\/[^'"`\s]+)['"`]/g;
38
+
39
+ /** The public entry file (repository-relative) of an owner unit, or null when the owner has none in the graph. */
40
+ export function entryOf(graph, config, key, owner) {
41
+ const declared = config.owners?.find(item => item.id === key)?.entry;
42
+ if (declared && graph.files.has(declared)) return declared;
43
+ for (const candidate of owner.tier === 'package' ? PACKAGE_ENTRIES : OWNER_ENTRIES) {
44
+ const rel = owner.root ? `${owner.root}/${candidate}` : candidate;
45
+ if (graph.files.has(rel)) return rel;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** Names an import/export declaration takes from its target: {all} or {pairs: [{imported, exported}]}. */
51
+ function bindingsOf(ts, declaration) {
52
+ const kind = ts.SyntaxKind;
53
+ if (!declaration) return { all: true, pairs: [] };
54
+ if (declaration.kind === kind.ImportDeclaration) {
55
+ const clause = declaration.importClause;
56
+ if (!clause) return { all: false, pairs: [] }; // side-effect import: uses nothing
57
+ const pairs = [];
58
+ if (clause.name) pairs.push({ imported: 'default', exported: null });
59
+ const named = clause.namedBindings;
60
+ if (named && named.kind === kind.NamespaceImport) return { all: true, pairs: [] };
61
+ if (named) for (const element of named.elements) pairs.push({ imported: (element.propertyName ?? element.name).text, exported: null });
62
+ return { all: false, pairs };
63
+ }
64
+ if (declaration.kind === kind.ExportDeclaration) {
65
+ const clause = declaration.exportClause;
66
+ if (!clause || clause.kind === kind.NamespaceExport) return { all: true, pairs: [] };
67
+ return { all: false, pairs: clause.elements.map(element => ({ imported: (element.propertyName ?? element.name).text, exported: element.name.text })) };
68
+ }
69
+ if (declaration.kind === kind.ImportTypeNode) {
70
+ let qualifier = declaration.qualifier;
71
+ if (!qualifier) return { all: true, pairs: [] };
72
+ while (qualifier.kind === kind.QualifiedName) qualifier = qualifier.left;
73
+ return { all: false, pairs: [{ imported: qualifier.text, exported: null }] };
74
+ }
75
+ // ImportEqualsDeclaration, dynamic import(), require(): the whole module is taken.
76
+ return { all: true, pairs: [] };
77
+ }
78
+
79
+ /** The exported names of an entry source file, each with the line of its declaration. */
80
+ export function exportedNames(ts, sourceFile) {
81
+ const kind = ts.SyntaxKind;
82
+ const found = new Map();
83
+ const add = (name, node) => {
84
+ if (found.has(name)) return;
85
+ found.set(name, sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1);
86
+ };
87
+ const bindingNames = (name, node) => {
88
+ if (name.kind === kind.Identifier) add(name.text, node);
89
+ else for (const element of name.elements) if (element.kind === kind.BindingElement) bindingNames(element.name, node);
90
+ };
91
+ for (const statement of sourceFile.statements) {
92
+ if (statement.kind === kind.ExportDeclaration) {
93
+ if (statement.exportClause?.kind === kind.NamedExports) for (const element of statement.exportClause.elements) add(element.name.text, element);
94
+ else if (statement.exportClause?.kind === kind.NamespaceExport) add(statement.exportClause.name.text, statement);
95
+ continue;
96
+ }
97
+ if (statement.kind === kind.ExportAssignment) { add('default', statement); continue; }
98
+ const modifiers = statement.modifiers ?? [];
99
+ if (!modifiers.some(modifier => modifier.kind === kind.ExportKeyword)) continue;
100
+ if (modifiers.some(modifier => modifier.kind === kind.DefaultKeyword)) { add('default', statement); continue; }
101
+ if (statement.kind === kind.VariableStatement) for (const declaration of statement.declarationList.declarations) bindingNames(declaration.name, declaration);
102
+ else if (statement.name && statement.name.kind === kind.Identifier) add(statement.name.text, statement);
103
+ }
104
+ return found;
105
+ }
106
+
107
+ const withoutSlash = value => value.replace(/\/$/, '');
108
+
109
+ /** The files the graph serves from: what an app slot requires, every route file, every package entry, every config-named file. */
110
+ function rootFiles(graph, config) {
111
+ const roots = new Set();
112
+ for (const [rel, node] of graph.files) {
113
+ if (node.tier === 'route') { roots.add(rel); continue; }
114
+ if (!node.owner || node.tier !== 'app') continue;
115
+ const slot = graph.resolver.slot(node.owner.slot);
116
+ const inside = rel.slice(withoutSlash(node.owner.root).length + 1);
117
+ if ((slot?.requires ?? []).some(name => name === inside)) roots.add(rel);
118
+ }
119
+ for (const [key, owner] of graph.ownerRoots) {
120
+ if (owner.tier !== 'package') continue;
121
+ const entry = entryOf(graph, config, key, owner);
122
+ if (entry) roots.add(entry);
123
+ }
124
+ // A framework config of an app (slot fe.app.next) names sources by string, never by import.
125
+ for (const app of config.apps ?? []) {
126
+ const directory = `apps/${app.name}`;
127
+ let names = [];
128
+ try { names = fs.readdirSync(path.join(config.root, ...directory.split('/'))); } catch { continue; }
129
+ for (const name of names) {
130
+ if (graph.resolver.classifyPath(`${directory}/${name}`).slot !== 'fe.app.next' || !/\.[cm]?[jt]sx?$/.test(name)) continue;
131
+ let text = '';
132
+ try { text = fs.readFileSync(path.join(config.root, ...directory.split('/'), name), 'utf8'); } catch { continue; }
133
+ for (const match of text.matchAll(CONFIG_STRING)) {
134
+ const target = path.posix.join(directory, match[1]);
135
+ const hit = CONFIG_EXTENSIONS.map(extension => `${target}${extension}`).find(candidate => graph.files.has(candidate));
136
+ if (hit) roots.add(hit);
137
+ }
138
+ }
139
+ }
140
+ return roots;
141
+ }
142
+
143
+ /** HFS_UNUSED_FILE: production files no root reaches through runtime or type-only imports and re-exports. */
144
+ function deadFiles(graph, config) {
145
+ const roots = rootFiles(graph, config);
146
+ const forward = new Map();
147
+ for (const edge of graph.edges) {
148
+ if (!forward.has(edge.from)) forward.set(edge.from, []);
149
+ forward.get(edge.from).push(edge.to);
150
+ }
151
+ const reached = new Set(roots);
152
+ const queue = [...roots];
153
+ while (queue.length) for (const next of forward.get(queue.shift()) ?? []) if (!reached.has(next)) { reached.add(next); queue.push(next); }
154
+ const violations = [];
155
+ let judged = 0;
156
+ for (const [rel, node] of [...graph.files].sort(([a], [b]) => a.localeCompare(b))) {
157
+ if (node.tier === null || UNJUDGED_TIERS.has(node.tier) || node.tier === 'route') continue;
158
+ judged += 1;
159
+ if (reached.has(rel)) continue;
160
+ violations.push({
161
+ ruleId: 'HFS_UNUSED_FILE',
162
+ path: rel, line: 1, column: 1,
163
+ slot: node.slot, owner: node.owner?.root ?? null,
164
+ message: `${rel} is not reached from any root (an app main.ts or app.module.ts, a route file, a package entry): nothing imports it, so no process runs it. Delete the file, or import it where it is used (a spec alone does not count).`,
165
+ });
166
+ }
167
+ return { violations, judged, roots: roots.size };
168
+ }
169
+
170
+ const TEST_CONSUMER = /^(?:(?:src|apps)\/.+\.service\.spec\.ts|src\/tests\/fixtures\/builders\/.+\.builder\.ts)$/u;
171
+
172
+ /** Pseudo edges from the unit specs of services and the fixture builders (read from disk, outside the production program) to graph files. */
173
+ function testConsumerEdges({ context, graph, config }) {
174
+ const { ts } = context;
175
+ const options = context.projects?.[0]?.options ?? {};
176
+ const edges = [];
177
+ for (const rel of [...treeOf(config.root).files].filter(file => TEST_CONSUMER.test(file)).sort()) {
178
+ const abs = path.join(config.root, ...rel.split('/'));
179
+ let text;
180
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
181
+ const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true);
182
+ for (const statement of sourceFile.statements) {
183
+ if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
184
+ const resolved = ts.resolveModuleName(statement.moduleSpecifier.text, abs, options, ts.sys).resolvedModule?.resolvedFileName;
185
+ const to = resolved ? graph.abs(canonical(resolved)) : null;
186
+ if (to) edges.push({ from: rel, to, edge: { declaration: statement }, reexport: false });
187
+ }
188
+ }
189
+ return edges;
190
+ }
191
+
192
+ export function checkDeadExports({ context, graph, config }) {
193
+ const { ts } = context;
194
+ const violations = [];
195
+ const edgesTo = new Map();
196
+ const unitOf = rel => {
197
+ const known = graph.unit(rel);
198
+ if (known) return known;
199
+ const classified = graph.resolver.classifyPath(rel);
200
+ const owner = classified.slot ? graph.resolver.ownerOf(rel) : null;
201
+ return owner ? `${owner.slot}:${owner.root}` : null;
202
+ };
203
+ for (const edge of [...graph.edges, ...testConsumerEdges({ context, graph, config })]) {
204
+ if (!edgesTo.has(edge.to)) edgesTo.set(edge.to, []);
205
+ edgesTo.get(edge.to).push(edge);
206
+ }
207
+
208
+ /** What the importers of `file` take from it; edges from inside `ownerKey` are skipped when given. */
209
+ const consumed = (file, ownerKey, depth, visiting) => {
210
+ const result = { all: false, names: new Set() };
211
+ for (const edge of edgesTo.get(file) ?? []) {
212
+ if (ownerKey && unitOf(edge.from) === ownerKey) continue;
213
+ const bindings = bindingsOf(ts, edge.edge.declaration);
214
+ if (bindings.all) { result.all = true; continue; }
215
+ for (const pair of bindings.pairs) {
216
+ if (pair.exported === null) { result.names.add(pair.imported); continue; }
217
+ // A re-export by name: the name is used when the re-exporting file's own export is used further on.
218
+ const reexporter = edge.from;
219
+ if (visiting.has(reexporter) || depth >= CHAIN_DEPTH) { result.names.add(pair.imported); continue; }
220
+ if (!(edgesTo.get(reexporter)?.length)) {
221
+ // nobody imports the re-exporting file: a framework entry (route file) uses it, an unused owner entry does not
222
+ const holder = graph.files.get(reexporter);
223
+ if (!holder?.owner || graph.resolver.slot(holder.owner.slot)?.tier === 'app') result.names.add(pair.imported);
224
+ continue;
225
+ }
226
+ const next = consumed(reexporter, null, depth + 1, new Set([...visiting, reexporter]));
227
+ if (next.all || next.names.has(pair.exported)) result.names.add(pair.imported);
228
+ }
229
+ }
230
+ return result;
231
+ };
232
+
233
+ let owners = 0;
234
+ let exports = 0;
235
+ for (const [key, owner] of graph.ownerRoots) {
236
+ if (owner.tier === 'app') continue;
237
+ const entry = entryOf(graph, config, key, owner);
238
+ if (!entry) continue;
239
+ owners += 1;
240
+ const names = exportedNames(ts, graph.files.get(entry).sourceFile);
241
+ const used = consumed(entry, key, 0, new Set([entry]));
242
+ // A consumer outside the owner that imports a file the entry re-exports by name reaches the entry's export of that name.
243
+ for (const edge of graph.edges) {
244
+ if (edge.from !== entry || !edge.reexport || graph.unit(edge.to) !== key) continue;
245
+ const reexport = bindingsOf(ts, edge.edge.declaration);
246
+ const inner = consumed(edge.to, key, 0, new Set([entry, edge.to]));
247
+ if (reexport.all) continue;
248
+ for (const pair of reexport.pairs) if (inner.all || inner.names.has(pair.imported)) used.names.add(pair.exported);
249
+ }
250
+ for (const [name, line] of names) {
251
+ exports += 1;
252
+ if (used.all || used.names.has(name)) continue;
253
+ violations.push({
254
+ ruleId: 'HFS_UNUSED_EXPORT',
255
+ path: entry, line, column: 1,
256
+ name, owner: owner.root, slot: owner.slot,
257
+ message: `${entry} exports ${name}, but no production file outside ${owner.root || 'the repository root'} imports it; remove the export (only a service unit spec or a fixture builder counts besides production files).`,
258
+ });
259
+ }
260
+ }
261
+ const files = deadFiles(graph, config);
262
+ const deadExports = violations.length;
263
+ violations.push(...files.violations);
264
+ return { violations, coverage: { status: 'checked', owners, exports, dead: deadExports, files: files.judged, roots: files.roots, unusedFiles: files.violations.length } };
265
+ }
@@ -0,0 +1,129 @@
1
+ import { machineKit } from './machine-ast.mjs';
2
+
3
+ /**
4
+ * R41 `default-deny-app-guard` (BE_DEFAULT_DENY). Every api app root provides its `APP_GUARD` entries in the order
5
+ * throttler -> CSRF origin guard -> AuthGuard, each exactly once, so no request reaches a handler before it was
6
+ * rate-limited, origin-checked and authenticated. A guard is classified by structure, never by name or folder alone
7
+ * (BE-CONVENTION 1.7 names the roles, not a library):
8
+ * - throttler: a class of @nestjs/throttler or one that extends it, or a guard that reads through a Reflector the
9
+ * per-door metadata (the `@RateLimit(tier)` decorator) that a SetMetadata call of its own capability writes;
10
+ * - CSRF origin guard: a guard declared in platform/http-security that reads the request origin or referer header;
11
+ * - AuthGuard: the `AuthGuard` class declared in domain/identity.
12
+ * Other guards are permitted between them; a missing, repeated or reordered one of the three is a finding.
13
+ */
14
+ export const DEFAULT_DENY_RULE_IDS = ['BE_DEFAULT_DENY'];
15
+
16
+ const RULE = 'BE_DEFAULT_DENY';
17
+ const ORDER = ['throttler', 'csrf', 'auth'];
18
+ const ORIGIN_HEADERS = new Set(['origin', 'referer', 'referrer']);
19
+ const METADATA_READERS = new Set(['get', 'getAll', 'getAllAndMerge', 'getAllAndOverride']);
20
+ const LABEL = { throttler: 'the throttler guard', csrf: 'the CSRF origin guard of platform/http-security', auth: 'AuthGuard of domain/identity' };
21
+
22
+ export function checkDefaultDeny(input) {
23
+ const { config } = input;
24
+ const kit = machineKit(input);
25
+ const { ts } = kit;
26
+ const violations = [];
27
+ let apps = 0;
28
+
29
+ const extendsThrottler = (declaration, depth = 0) => {
30
+ if (!ts.isClassDeclaration(declaration) || depth > 8) return false;
31
+ const checker = kit.checkerOf(declaration.getSourceFile());
32
+ if (!checker) return false;
33
+ for (const clause of declaration.heritageClauses ?? []) {
34
+ for (const type of clause.types) {
35
+ if (kit.isImportOf(checker, type.expression, 'ThrottlerGuard', '@nestjs/throttler')) return true;
36
+ if (kit.declarationsOf(checker, type.expression).some(parent => parent !== declaration && extendsThrottler(parent, depth + 1))) return true;
37
+ }
38
+ }
39
+ return false;
40
+ };
41
+
42
+ const keySymbol = (checker, node) => (node ? kit.aliased(checker, kit.symbolAt(checker, node)) ?? null : null);
43
+
44
+ /** The symbols a SetMetadata call of the owner writes as metadata keys: what its door decorators (`@RateLimit(tier)`) attach. */
45
+ const writtenKeys = (owner, cache = new Map()) => {
46
+ if (cache.has(owner.root)) return cache.get(owner.root);
47
+ const keys = new Set();
48
+ for (const file of input.graph.files.values()) {
49
+ if (file.owner?.root !== owner.root) continue;
50
+ const checker = kit.checkerOf(file.sourceFile);
51
+ if (!checker) continue;
52
+ kit.walk(file.sourceFile, node => {
53
+ if (ts.isCallExpression(node) && kit.isImportOf(checker, node.expression, 'SetMetadata', '@nestjs/common')) {
54
+ const key = keySymbol(checker, node.arguments[0]);
55
+ if (key) keys.add(key);
56
+ }
57
+ return true;
58
+ });
59
+ }
60
+ cache.set(owner.root, keys);
61
+ return keys;
62
+ };
63
+ const keyCache = new Map();
64
+
65
+ /** True when the class reads a request origin or referer header (the origin/referer allowlist of the CSRF guard). */
66
+ const readsOrigin = declaration => {
67
+ let found = false;
68
+ kit.walk(declaration, node => {
69
+ if (ts.isPropertyAccessExpression(node) && ORIGIN_HEADERS.has(node.name.text.toLowerCase())) found = true;
70
+ else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) && ORIGIN_HEADERS.has(node.argumentExpression.text.toLowerCase())) found = true;
71
+ return !found;
72
+ });
73
+ return found;
74
+ };
75
+
76
+ /** True when the class reads, through a metadata getter, a key its own capability's SetMetadata decorators write. */
77
+ const readsDoorMetadata = (declaration, owner) => {
78
+ const checker = kit.checkerOf(declaration.getSourceFile());
79
+ if (!checker) return false;
80
+ const keys = writtenKeys(owner, keyCache);
81
+ let found = false;
82
+ kit.walk(declaration, node => {
83
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && METADATA_READERS.has(node.expression.name.text)) {
84
+ const key = keySymbol(checker, node.arguments[0]);
85
+ if (key && keys.has(key)) found = true;
86
+ }
87
+ return !found;
88
+ });
89
+ return found;
90
+ };
91
+
92
+ const classify = (checker, node) => {
93
+ if (!node) return null;
94
+ if (kit.importBinding(checker, node)?.module === '@nestjs/throttler') return 'throttler';
95
+ const declarations = kit.declarationsOf(checker, node);
96
+ if (declarations.some(declaration => extendsThrottler(declaration))) return 'throttler';
97
+ for (const declaration of declarations) {
98
+ const owner = kit.ownerOfDeclaration(declaration);
99
+ if (owner?.tier === 'platform' && owner.name === 'http-security' && ts.isClassDeclaration(declaration)) {
100
+ if (readsOrigin(declaration)) return 'csrf';
101
+ if (readsDoorMetadata(declaration, owner)) return 'throttler';
102
+ }
103
+ if (owner?.tier === 'domain' && owner.name === 'identity' && ts.isClassDeclaration(declaration) && declaration.name?.text === 'AuthGuard') return 'auth';
104
+ }
105
+ return null;
106
+ };
107
+
108
+ for (const app of config.apps.filter(item => item.kind === 'api')) {
109
+ const root = kit.appRoot(app.name);
110
+ if (!root) continue;
111
+ apps += 1;
112
+ const guards = kit.providersOf(root, 'APP_GUARD', '@nestjs/core');
113
+ const roles = guards.map(guard => ({ guard, role: classify(guard.checker, guard.useClass) }));
114
+ const anchor = guards[0]?.node ?? root.sourceFile.statements[0] ?? root.sourceFile;
115
+ const finding = (node, message) => violations.push({ ruleId: RULE, path: root.rel, ...kit.at(root.rel, root.sourceFile, node), app: app.name, message });
116
+ for (const role of ORDER) {
117
+ const entries = roles.filter(item => item.role === role);
118
+ if (entries.length === 0) finding(anchor, `App ${app.name} does not provide ${LABEL[role]} as an APP_GUARD; an api app is denied by default: throttler, then CSRF origin guard, then AuthGuard.`);
119
+ else if (entries.length > 1) finding(entries[1].guard.node, `App ${app.name} provides ${LABEL[role]} more than once as an APP_GUARD.`);
120
+ }
121
+ const indexes = ORDER.map(role => roles.findIndex(item => item.role === role));
122
+ if (indexes.every(index => index >= 0)) {
123
+ for (let i = 1; i < indexes.length; i += 1) {
124
+ if (indexes[i] < indexes[i - 1]) finding(roles[indexes[i]].guard.node, `App ${app.name} provides ${LABEL[ORDER[i]]} before ${LABEL[ORDER[i - 1]]}; the APP_GUARD order is throttler, CSRF origin guard, AuthGuard.`);
125
+ }
126
+ }
127
+ }
128
+ return { violations, coverage: { status: 'checked', apps } };
129
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { treeOf } from './required-files.mjs';
4
+ import { documentLanguageHits, isDocument, isLocalizedDataFile } from '../../lib/language.mjs';
5
+
6
+ /**
7
+ * R96 `doc-language` (HFS_DOC_NOT_ENGLISH). Every Markdown and YAML document under knowledge/, docs/, src/ and apps/ is English:
8
+ * prose, patterns and examples alike, and a code fence inside Markdown counts because an example is read exactly like the
9
+ * prose around it. Detection is structural on characters (scripts/lib/language.mjs), never a word list.
10
+ *
11
+ * The one exception is data, decided by placement: a YAML file that sits in a slot carrying localized text (a message
12
+ * catalog slot or an i18n fixtures slot, listed in scripts/lib/language.mjs) may hold another language. Markdown is never data, so a README
13
+ * inside a catalog slot is judged like any other document. No comment pragma, no path pattern.
14
+ */
15
+ export const DOC_LANGUAGE_RULE_IDS = ['HFS_DOC_NOT_ENGLISH'];
16
+
17
+ const RULE = 'HFS_DOC_NOT_ENGLISH';
18
+ /** The top-level folders whose documents this check reads. */
19
+ export const DOCUMENT_ROOTS = Object.freeze(['knowledge', 'docs', 'src', 'apps']);
20
+
21
+ export function checkDocLanguage({ config, graph }) {
22
+ const resolver = graph.resolver;
23
+ const tree = treeOf(config.root);
24
+ const violations = [];
25
+ let files = 0;
26
+ for (const file of [...tree.files].sort()) {
27
+ if (!DOCUMENT_ROOTS.includes(file.split('/')[0]) || !isDocument(file) || file.split('/').includes('node_modules')) continue;
28
+ const classified = resolver.classifyPath(file);
29
+ if (isLocalizedDataFile(file, classified.status === 'owned' ? classified.slot : null)) continue;
30
+ let text;
31
+ try { text = fs.readFileSync(path.join(config.root, ...file.split('/')), 'utf8'); } catch { continue; }
32
+ files += 1;
33
+ for (const hit of documentLanguageHits(file, text)) {
34
+ violations.push({ ruleId: RULE, path: file, line: hit.line, column: hit.column,
35
+ message: `${file}:${hit.line} carries a Vietnamese letter. Documents are English (prose, patterns, examples and code fences); only YAML data in a message-catalog or i18n-fixtures slot may hold another language. Write the line in English.` });
36
+ }
37
+ }
38
+ return { violations, coverage: { status: 'checked', files } };
39
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'node:path';
2
+ import { machineKit } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R33 `entrypoint-only-in-apps` (BE_ENTRYPOINT_ONLY_IN_APPS). A process starts in `apps/<app>/src/main.ts` only:
6
+ * `NestFactory.create*` (from @nestjs/core) and a top-level `bootstrap()` call anywhere else are entrypoints hiding in a
7
+ * library, a feature, a spec helper or another file of an app.
8
+ *
9
+ * The test world (slot be.tests.world) is the test COMPOSITION ROOT, like an app's main.ts: use-test-world.ts boots the
10
+ * real apps with NestFactory the way main.ts does in production, so it is an entrypoint owner too. A spec
11
+ * (*.integration-spec.ts, *.contract-spec.ts, *.e2e-spec.ts) does not compose: it calls useTestWorld(...) only, and the
12
+ * other test slots stay refused.
13
+ */
14
+ export const ENTRYPOINT_RULE_IDS = ['BE_ENTRYPOINT_ONLY_IN_APPS'];
15
+
16
+ const RULE = 'BE_ENTRYPOINT_ONLY_IN_APPS';
17
+
18
+ export function checkEntrypoints(input) {
19
+ const { graph } = input;
20
+ const kit = machineKit(input);
21
+ const { ts } = kit;
22
+ const violations = [];
23
+ let entrypoints = 0;
24
+ /** The identifier a top-level statement calls: `bootstrap()`, `void bootstrap()`, `bootstrap().catch(...)`. */
25
+ const calledIdentifier = expression => {
26
+ let current = expression;
27
+ for (;;) {
28
+ if (ts.isVoidExpression(current) || ts.isAwaitExpression(current) || ts.isParenthesizedExpression(current) || ts.isNonNullExpression(current)) current = current.expression;
29
+ else if (ts.isCallExpression(current)) {
30
+ if (ts.isIdentifier(current.expression)) return current.expression;
31
+ current = current.expression;
32
+ } else if (ts.isPropertyAccessExpression(current)) current = current.expression;
33
+ else return null;
34
+ }
35
+ };
36
+ for (const file of graph.files.values()) {
37
+ const isMain = (Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'main.ts') || file.slot === 'be.tests.world';
38
+ const checker = kit.checkerOf(file.sourceFile);
39
+ const report = (node, message) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message });
40
+ kit.walk(file.sourceFile, node => {
41
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text.startsWith('create')
42
+ && kit.isImportOf(checker, node.expression.expression, 'NestFactory', '@nestjs/core')) {
43
+ if (isMain) entrypoints += 1;
44
+ else report(node, `NestFactory.${node.expression.name.text}() starts a process outside apps/<app>/src/main.ts; entrypoints live in an app's main.ts only.`);
45
+ }
46
+ return true;
47
+ });
48
+ if (isMain) continue;
49
+ for (const statement of file.sourceFile.statements) {
50
+ if (!ts.isExpressionStatement(statement)) continue;
51
+ if (calledIdentifier(statement.expression)?.text === 'bootstrap') {
52
+ report(statement, "A top-level bootstrap() call starts a process outside apps/<app>/src/main.ts; entrypoints live in an app's main.ts only.");
53
+ }
54
+ }
55
+ }
56
+ return { violations, coverage: { status: 'checked', entrypoints } };
57
+ }
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+ import { machineKit, upperSnake } from './machine-ast.mjs';
3
+
4
+ /**
5
+ * R38 error codes (BE_ERROR_HOME, machine half; the eslint half judges the error classes). The string value of every
6
+ * member of a `<C>ErrorCode` enum in a capability's `errors/<c>.error.ts` is a string literal shaped
7
+ * `<CAPABILITY>_<WHAT>` (UPPER_SNAKE of the owning capability, no `_ERROR` or `_EXCEPTION` suffix) and unique across the
8
+ * whole repository, so a code names one refusal and one capability for the client and the log.
9
+ */
10
+ export const ERROR_CODE_RULE_IDS = ['BE_ERROR_HOME'];
11
+
12
+ const RULE = 'BE_ERROR_HOME';
13
+
14
+ export function checkErrorCodes(input) {
15
+ const { graph } = input;
16
+ const kit = machineKit(input);
17
+ const { ts } = kit;
18
+ const violations = [];
19
+ const seen = new Map();
20
+ let enums = 0;
21
+ let codes = 0;
22
+ const files = [...graph.files.values()].filter(file => file.slot === 'be.errors' && file.owner && path.posix.basename(file.rel).endsWith('.error.ts'))
23
+ .sort((a, b) => a.rel.localeCompare(b.rel));
24
+ for (const file of files) {
25
+ const capability = upperSnake(path.posix.basename(file.owner.root));
26
+ const shape = new RegExp(`^${capability}_[A-Z0-9]+(?:_[A-Z0-9]+)*$`, 'u');
27
+ for (const statement of file.sourceFile.statements) {
28
+ if (!ts.isEnumDeclaration(statement) || !statement.name.text.endsWith('ErrorCode')) continue;
29
+ enums += 1;
30
+ for (const member of statement.members) {
31
+ const report = message => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, member), message });
32
+ const name = kit.propertyNameText(member.name) ?? '?';
33
+ const value = member.initializer && ts.isStringLiteralLike(member.initializer) ? member.initializer.text : null;
34
+ if (value === null) { report(`${statement.name.text}.${name} must be a string literal code (\`${capability}_<WHAT>\`); a computed or numeric code cannot be checked or told apart in a log.`); continue; }
35
+ codes += 1;
36
+ if (!shape.test(value) || /_(?:ERROR|EXCEPTION)$/u.test(value)) {
37
+ report(`${statement.name.text}.${name} = "${value}" must match ${capability}_<WHAT> in UPPER_SNAKE with the capability prefix and no _ERROR or _EXCEPTION suffix.`);
38
+ }
39
+ if (seen.has(value)) report(`Error code "${value}" is already declared in ${seen.get(value)}; a code is unique across the repository.`);
40
+ else seen.set(value, file.rel);
41
+ }
42
+ }
43
+ }
44
+ return { violations, coverage: { status: 'checked', enums, codes } };
45
+ }