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