@starci/hfs 4.0.10 → 4.1.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 (271) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +3 -2
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +187 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +221 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/removed-vocabulary.mjs +28 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/runtime-root.mjs +17 -0
  16. package/runtime/engine/secrets.mjs +35 -31
  17. package/runtime/engine/sonar-config.mjs +21 -0
  18. package/runtime/engine/temp-root.mjs +32 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  20. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  21. package/runtime/knowledge/hfs/rules.yaml +98 -65
  22. package/runtime/knowledge/hfs/slots.yaml +11 -14
  23. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  24. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  25. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  26. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  27. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  28. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  29. package/runtime/modules/models/registry.yaml +25 -359
  30. package/runtime/modules/models/runtimes.yaml +92 -256
  31. package/runtime/modules/models/tiers.yaml +65 -0
  32. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  33. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  34. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  35. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  36. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  37. package/runtime/scripts/api/git/lib.mjs +34 -1
  38. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  39. package/runtime/scripts/api/process/run-program.mjs +2 -1
  40. package/runtime/scripts/api/sops/lib.mjs +198 -120
  41. package/runtime/scripts/hfs/allows.mjs +3 -3
  42. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  43. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  44. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  45. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  47. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  48. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  49. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  50. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  51. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  52. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  53. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  54. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  55. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  56. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  57. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  58. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  59. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  60. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  61. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  62. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  63. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  65. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  66. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  67. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  69. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  70. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  71. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  72. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  74. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  75. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  76. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  77. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  78. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  79. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  80. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  81. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  82. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  83. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  84. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  85. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  86. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  87. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  88. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  89. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  90. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  91. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  92. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  93. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  94. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  95. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  96. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  97. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  98. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  99. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  100. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  101. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  102. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  103. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  104. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  105. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  106. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  107. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  108. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  109. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  110. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  111. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  112. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  113. package/runtime/scripts/hfs/architecture.mjs +3 -4
  114. package/runtime/scripts/hfs/check.mjs +61 -70
  115. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  116. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  117. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  118. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  119. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  120. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  121. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  122. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  123. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  124. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  125. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  126. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  127. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  128. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  129. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  130. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  131. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  132. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  133. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  134. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  135. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  136. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  137. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  138. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  139. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  140. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  141. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  142. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  143. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  144. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  145. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  146. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  147. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  148. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  149. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  150. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  151. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  152. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  153. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  154. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  155. package/runtime/scripts/hfs/secret.mjs +59 -48
  156. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  157. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  158. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  159. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  160. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  161. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  162. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  163. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  165. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  166. package/runtime/scripts/hfs/slots.mjs +72 -693
  167. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  168. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  169. package/runtime/scripts/hfs/tree.mjs +19 -14
  170. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  171. package/runtime/scripts/hfs/view.mjs +1 -1
  172. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  173. package/runtime/scripts/lib/event-contract.mjs +17 -13
  174. package/runtime/scripts/lib/git.mjs +2 -2
  175. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  176. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  177. package/runtime/scripts/lib/i18n.mjs +3 -2
  178. package/runtime/scripts/lib/in-order.mjs +71 -0
  179. package/runtime/scripts/lib/language.mjs +3 -3
  180. package/runtime/scripts/lib/list.mjs +8 -1
  181. package/runtime/scripts/lib/path-key.mjs +38 -5
  182. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  183. package/runtime/scripts/lib/regex.mjs +1 -1
  184. package/runtime/scripts/lib/same-text.mjs +1 -1
  185. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  186. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  187. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  188. package/runtime/scripts/lib/stack-services.mjs +1 -2
  189. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  190. package/runtime/scripts/lib/walk.mjs +6 -1
  191. package/scaffold/add-cli-lite.mjs +2 -1
  192. package/scaffold/add-table.mjs +2 -2
  193. package/scaffold/add.mjs +3 -2
  194. package/scaffold/app.mjs +4 -2
  195. package/scaffold/edition-gate.mjs +25 -25
  196. package/scaffold/lite-exports.mjs +2 -1
  197. package/scaffold/service.mjs +5 -4
  198. package/sync/hygiene.mjs +15 -9
  199. package/sync/index.mjs +7 -3
  200. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  201. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  202. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  203. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  204. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  205. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  206. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  207. package/templates/app/starciwork.gitignore +1 -1
  208. package/templates/be/image/api/Dockerfile +1 -1
  209. package/templates/be/image/cli/Dockerfile +1 -1
  210. package/templates/be/image/worker/Dockerfile +1 -1
  211. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  212. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  213. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  214. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  215. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  216. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  217. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  218. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  219. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  220. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  221. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  222. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  223. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  224. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  225. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  226. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  227. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  228. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  229. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  230. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  231. package/templates/fe/image/next/Dockerfile +2 -1
  232. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  233. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  234. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  235. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  236. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  238. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  239. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  241. package/upgrade/index.mjs +9 -2
  242. package/runtime/engine/admission.mjs +0 -308
  243. package/runtime/engine/canonical-json.mjs +0 -10
  244. package/runtime/engine/db/blob.mjs +0 -315
  245. package/runtime/engine/db/ledger-paths.mjs +0 -83
  246. package/runtime/engine/db/ledger.mjs +0 -1118
  247. package/runtime/engine/db/machine-connection.mjs +0 -135
  248. package/runtime/engine/db/machine-schema.mjs +0 -84
  249. package/runtime/engine/db/machine.mjs +0 -1367
  250. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  251. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  252. package/runtime/engine/db/provider-reservations.mjs +0 -101
  253. package/runtime/engine/digest.mjs +0 -16
  254. package/runtime/engine/refuse.mjs +0 -11
  255. package/runtime/modules/ops/_labels.yaml +0 -53
  256. package/runtime/scripts/api/node/lib.mjs +0 -14
  257. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  258. package/runtime/scripts/api/process/lib.mjs +0 -111
  259. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  260. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  261. package/runtime/scripts/connectors/lib.mjs +0 -488
  262. package/runtime/scripts/lib/clip.mjs +0 -19
  263. package/runtime/scripts/lib/display-names.mjs +0 -259
  264. package/runtime/scripts/lib/example-refs.mjs +0 -158
  265. package/runtime/scripts/lib/json-schema.mjs +0 -52
  266. package/runtime/scripts/lib/process-identity.mjs +0 -6
  267. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  268. package/runtime/scripts/lib/redact.mjs +0 -161
  269. package/runtime/scripts/lib/sleep.mjs +0 -7
  270. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  271. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -31,6 +31,10 @@ function ownerOf(owners, file) {
31
31
  return owners.find(owner => isInside(owner.root, file)) ?? null;
32
32
  }
33
33
 
34
+ function reexportSteps(context, current) {
35
+ return (context.edges.get(current.file) ?? []).filter(candidate => candidate.reexport).map(candidate => ({ file: candidate.to, chain: [...current.chain, candidate.to] }));
36
+ }
37
+
34
38
  function privateOwnerChain(context, owners, actionEntries, edge) {
35
39
  const sourceOwner = ownerOf(owners, edge.from);
36
40
  const queue = [{ file: edge.to, chain: [edge.from, edge.to] }];
@@ -41,9 +45,7 @@ function privateOwnerChain(context, owners, actionEntries, edge) {
41
45
  visited.add(current.file);
42
46
  const owner = ownerOf(owners, current.file);
43
47
  if (owner && sourceOwner?.id !== owner.id) return owner.entries.has(current.file) || actionEntries.has(current.file) ? null : { owner, chain: current.chain };
44
- for (const candidate of context.edges.get(current.file) ?? []) if (candidate.reexport) {
45
- queue.push({ file: candidate.to, chain: [...current.chain, candidate.to] });
46
- }
48
+ queue.push(...reexportSteps(context, current));
47
49
  }
48
50
  return null;
49
51
  }
@@ -59,15 +61,8 @@ function arrangesSchema(config, from, to) {
59
61
  && resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
60
62
  }
61
63
 
62
- /** Enforce explicit same-source owner entries without constraining imports inside one owner. */
63
- export function checkOwners(config, context) {
64
- if (!config.owners?.length) return [];
65
- const owners = ownerDeclarations(config, context);
64
+ function publicEntryViolations(config, context, owners, sourceFiles, actionEntries) {
66
65
  const violations = [];
67
- const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
68
- const actionEntries = new Set(config.kinds.includes('frontend')
69
- ? [...sourceFiles].filter(([, file]) => isServerActionModule(context.ts, file)).map(([name]) => name)
70
- : []);
71
66
  for (const owner of owners) {
72
67
  const sourceFile = sourceFiles.get(owner.entry);
73
68
  if (!sourceFile) {
@@ -87,11 +82,16 @@ export function checkOwners(config, context) {
87
82
  path: relativePath(config.root, canonical(entryFile.fileName)),
88
83
  ...sourceLocation(entryFile, statement),
89
84
  owner: owner.id,
90
- message: `Owner ${owner.id} public entry must use explicit named exports rather than export *.`
85
+ message: `Owner ${owner.id} public entry must use explicit named exports rather than export *.`,
91
86
  });
92
87
  }
93
88
  }
94
89
  }
90
+ return violations;
91
+ }
92
+
93
+ function ownerImportViolations(config, context, owners, actionEntries) {
94
+ const violations = [];
95
95
  for (const sourceFile of context.files) {
96
96
  const from = canonical(sourceFile.fileName);
97
97
  for (const edge of context.edges.get(from) ?? []) {
@@ -107,9 +107,23 @@ export function checkOwners(config, context) {
107
107
  specifier: edge.specifier,
108
108
  resolvedPath: relativePath(config.root, bypass.chain.at(-1)),
109
109
  dependencyChain: bypass.chain.map(file => relativePath(config.root, file)),
110
- message: `Cross-owner dependency must enter ${bypass.owner.id} through ${relativePath(config.root, bypass.owner.entry)}.`
110
+ message: `Cross-owner dependency must enter ${bypass.owner.id} through ${relativePath(config.root, bypass.owner.entry)}.`,
111
111
  });
112
112
  }
113
113
  }
114
114
  return violations;
115
115
  }
116
+
117
+ /** Enforce explicit same-source owner entries without constraining imports inside one owner. */
118
+ export function checkOwners(config, context) {
119
+ if (!config.owners?.length) return [];
120
+ const owners = ownerDeclarations(config, context);
121
+ const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
122
+ const actionEntries = new Set(config.kinds.includes('frontend')
123
+ ? [...sourceFiles].filter(([, file]) => isServerActionModule(context.ts, file)).map(([name]) => name)
124
+ : []);
125
+ return [
126
+ ...publicEntryViolations(config, context, owners, sourceFiles, actionEntries),
127
+ ...ownerImportViolations(config, context, owners, actionEntries),
128
+ ];
129
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { treeOf } from './required-files.mjs';
4
+ import { byCodeUnit } from '../../lib/list.mjs';
4
5
 
5
6
  /**
6
7
  * R63 `package-shape` (FE_PACKAGE_SHAPE, knowledge/hfs/README.md 6.2). A shared package is built to `dist` and says what it
@@ -18,6 +19,7 @@ import { treeOf } from './required-files.mjs';
18
19
  export const PACKAGE_SHAPE_RULE_IDS = ['FE_PACKAGE_SHAPE'];
19
20
 
20
21
  const RULE = 'FE_PACKAGE_SHAPE';
22
+ const UNREADABLE = Symbol('unreadable');
21
23
 
22
24
  /** Every string target of an exports value (a string, a condition map, an array of either), with the condition path. */
23
25
  function targetsOf(value, trail = []) {
@@ -29,35 +31,61 @@ function targetsOf(value, trail = []) {
29
31
 
30
32
  const insideDist = target => path.posix.normalize(target).replace(/^\.\//u, '').startsWith('dist/');
31
33
 
32
- export function checkPackageShape({ config, graph }) {
33
- const resolver = graph.resolver;
34
- const violations = [];
35
- let packages = 0;
34
+ function packageRoots(config, resolver) {
36
35
  const roots = new Set();
37
36
  for (const rel of treeOf(config.root).files) {
38
37
  const owner = resolver.ownerOf(rel);
39
38
  if (owner && resolver.slot(owner.slot)?.tier === 'package') roots.add(owner.root);
40
39
  }
41
- for (const root of [...roots].sort()) {
42
- const manifestRel = `${root}/package.json`;
43
- const report = (message, extra = {}) => violations.push({ ruleId: RULE, path: manifestRel, line: 1, column: 1, package: root, message, ...extra });
44
- let manifest;
45
- try { manifest = JSON.parse(fs.readFileSync(path.join(config.root, ...manifestRel.split('/')), 'utf8')); } catch { continue; }
46
- packages += 1;
47
- if (typeof manifest.scripts?.build !== 'string' || !manifest.scripts.build.trim()) report(`${manifestRel} has no scripts.build; a shared package is built to dist before an app consumes it.`);
48
- if (manifest.exports === undefined) {
49
- report(`${manifestRel} declares no exports map; a package lists what it exports, subpath by subpath, each pointing into ./dist/.`);
50
- } else {
51
- if (typeof manifest.exports === 'object' && !Array.isArray(manifest.exports)) {
52
- for (const key of Object.keys(manifest.exports)) if (key.includes('*') || key.endsWith('/')) report(`exports key "${key}" of ${manifestRel} is a pattern or folder mapping; list each exported subpath explicitly.`, { export: key });
53
- }
54
- for (const { target, trail } of targetsOf(manifest.exports)) {
55
- if (!insideDist(target)) report(`exports target "${target}" (${trail.join(' > ') || '.'}) of ${manifestRel} is not inside ./dist/; a package exports what it built, never its source.`, { export: target });
56
- }
57
- }
58
- for (const field of ['main', 'module', 'types', 'typings']) {
59
- if (typeof manifest[field] === 'string' && !insideDist(manifest[field])) report(`${field} "${manifest[field]}" of ${manifestRel} is not inside ./dist/; a package points at what it built.`, { field });
40
+ return [...roots].sort(byCodeUnit);
41
+ }
42
+
43
+ function readManifest(config, manifestRel) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(path.join(config.root, ...manifestRel.split('/')), 'utf8'));
46
+ } catch {
47
+ return UNREADABLE;
48
+ }
49
+ }
50
+
51
+ function reportExportIssues(manifest, manifestRel, report) {
52
+ if (manifest.exports === undefined) {
53
+ report(`${manifestRel} declares no exports map; a package lists what it exports, subpath by subpath, each pointing into ./dist/.`);
54
+ return;
55
+ }
56
+ if (typeof manifest.exports === 'object' && !Array.isArray(manifest.exports)) {
57
+ for (const key of Object.keys(manifest.exports)) {
58
+ if (key.includes('*') || key.endsWith('/')) report(`exports key "${key}" of ${manifestRel} is a pattern or folder mapping; list each exported subpath explicitly.`, { export: key });
60
59
  }
61
60
  }
61
+ for (const { target, trail } of targetsOf(manifest.exports)) {
62
+ if (!insideDist(target)) report(`exports target "${target}" (${trail.join(' > ') || '.'}) of ${manifestRel} is not inside ./dist/; a package exports what it built, never its source.`, { export: target });
63
+ }
64
+ }
65
+
66
+ function reportManifestIssues(manifest, manifestRel, report) {
67
+ if (typeof manifest.scripts?.build !== 'string' || !manifest.scripts.build.trim()) report(`${manifestRel} has no scripts.build; a shared package is built to dist before an app consumes it.`);
68
+ reportExportIssues(manifest, manifestRel, report);
69
+ for (const field of ['main', 'module', 'types', 'typings']) {
70
+ if (typeof manifest[field] === 'string' && !insideDist(manifest[field])) report(`${field} "${manifest[field]}" of ${manifestRel} is not inside ./dist/; a package points at what it built.`, { field });
71
+ }
72
+ }
73
+
74
+ function inspectPackage(config, root, violations) {
75
+ const manifestRel = `${root}/package.json`;
76
+ const report = (message, extra = {}) => violations.push({ ruleId: RULE, path: manifestRel, line: 1, column: 1, package: root, message, ...extra });
77
+ const manifest = readManifest(config, manifestRel);
78
+ if (manifest === UNREADABLE) return false;
79
+ reportManifestIssues(manifest, manifestRel, report);
80
+ return true;
81
+ }
82
+
83
+ export function checkPackageShape({ config, graph }) {
84
+ const roots = packageRoots(config, graph.resolver);
85
+ const violations = [];
86
+ let packages = 0;
87
+ for (const root of roots) {
88
+ if (inspectPackage(config, root, violations)) packages += 1;
89
+ }
62
90
  return { violations, coverage: { status: 'checked', packages } };
63
91
  }
@@ -0,0 +1,165 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { repositoryName } from '../repo-identity.mjs';
4
+ import { managedScriptNames } from './managed-scripts.mjs';
5
+ import { trimTrailingChars } from '../linear-text.mjs';
6
+
7
+ const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
8
+ const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
9
+ const README_SECTIONS = ['Overview', 'Stack', 'Repository layout', 'Development'];
10
+ const DEVELOPMENT_SCRIPTS = ['typecheck', 'lint', 'build', 'test'];
11
+ const README_HEADING = new RegExp(['^## ', '(.+?)', String.raw`\s*$`].join(''), 'u');
12
+ const README_URL = new RegExp(['https?:', String.raw`\/\/`, String.raw`[^\s<>)"']+`].join(''), 'giu');
13
+
14
+ function privateHost(hostname) {
15
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
16
+ if (host === 'localhost' || host === '::1' || host === '0.0.0.0' ||
17
+ /(?:\.localhost|\.local|\.internal|\.lan)$/u.test(host)) return true;
18
+ if (host.includes(':')) return /^(?:::|f[cd][0-9a-f]*:|fe[89ab][0-9a-f]*:)/iu.test(host);
19
+ if (!host.includes('.')) return true;
20
+ const octets = host.split('.');
21
+ if (octets.length !== 4 || !octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)) return false;
22
+ const [a, b] = octets.map(Number);
23
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 ||
24
+ a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
25
+ }
26
+
27
+ function scriptCommand(name) {
28
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
29
+ return new RegExp(name === 'test' ? String.raw`npm (?:run test|test)(?![\w:-])` : String.raw`npm run ${escaped}(?![\w:-])`, 'u');
30
+ }
31
+
32
+ function addFinding(state, ruleId, entry, message, line = 1) {
33
+ state.violations.push({ ruleId, path: entry, line, column: 1, message });
34
+ }
35
+
36
+ function checkRootPackageManager(state) {
37
+ try {
38
+ const pkg = JSON.parse(fs.readFileSync(path.join(state.root, 'package.json'), 'utf8'));
39
+ if (pkg.packageManager && !/^npm@\d/u.test(pkg.packageManager))
40
+ addFinding(state, 'HFS_PACKAGE_MANAGER_MIXED', 'package.json', `packageManager ${pkg.packageManager} contradicts the npm package-lock.json contract.`);
41
+ } catch { /* The repository's package/config checks own unreadable or invalid JSON. */ }
42
+ }
43
+
44
+ function checkRootPresentation(state) {
45
+ const { runtime, tree } = state;
46
+ for (const entry of tree.top) {
47
+ if (/\.md$/iu.test(entry) && !(runtime ? RUNTIME_ROOT_MARKDOWN : PRODUCT_ROOT_MARKDOWN).has(entry))
48
+ addFinding(state, 'HFS_ROOT_MARKDOWN_FORBIDDEN', entry, `Root Markdown ${entry} belongs under docs/ or the owning Work record.`);
49
+ if (state.nonNpmEntries.has(entry)) addFinding(state, 'HFS_PACKAGE_MANAGER_MIXED', entry, `${entry} contradicts the npm package manager contract.`);
50
+ }
51
+ for (const entry of ['.gitattributes', 'README.md']) {
52
+ if (!tree.hasFile(entry)) addFinding(state, 'HFS_ROOT_ENTRY_MISSING', entry, `Repository presentation requires root ${entry}.`);
53
+ }
54
+ if (tree.hasFile('package.json')) checkRootPackageManager(state);
55
+ }
56
+
57
+ function readmeSectionBody(lines, headings, section) {
58
+ const start = headings.find(item => item.name === section)?.index;
59
+ if (start === undefined) return '';
60
+ const end = headings.find(item => item.index > start)?.index ?? lines.length;
61
+ return lines.slice(start + 1, end).join('\n');
62
+ }
63
+
64
+ function checkReadmeHeadings(state, lines) {
65
+ const { root, runtime, tree } = state;
66
+ const name = runtime ? 'StarCi' : repositoryName(root);
67
+ if (lines[0].trim().toLowerCase() !== `# ${name}`.toLowerCase())
68
+ addFinding(state, 'HFS_README_TITLE_INVALID', 'README.md', `README.md must start with # ${name}.`);
69
+ const description = lines.slice(1).find(line => line.trim());
70
+ if (!description || /^\s*(?:#|!\[|\[!\[)/u.test(description) || description.trim().length > 240)
71
+ addFinding(state, 'HFS_README_DESCRIPTION_INVALID', 'README.md', 'Place one concise description line directly below the repository name.');
72
+ const headings = lines.map((line, index) => ({ name: README_HEADING.exec(line)?.[1], index })).filter(item => item.name);
73
+ const required = [...README_SECTIONS, ...(tree.hasDir('.starciwork') ? ['Work'] : [])];
74
+ let previous = -1;
75
+ for (const section of required) {
76
+ const found = headings.find(item => item.name === section);
77
+ if (!found) addFinding(state, 'HFS_README_SECTION_MISSING', 'README.md', `README.md requires a ## ${section} section.`);
78
+ else if (found.index <= previous) addFinding(state, 'HFS_README_SECTION_ORDER', 'README.md', `## ${section} must follow the preceding standard section.`, found.index + 1);
79
+ else previous = found.index;
80
+ }
81
+ return headings;
82
+ }
83
+
84
+ function checkReadmeDevelopment(state, lines, headings) {
85
+ if (state.runtime || !headings.some(item => item.name === 'Development')) return;
86
+ const development = readmeSectionBody(lines, headings, 'Development');
87
+ const scripts = DEVELOPMENT_SCRIPTS.filter(name => managedScriptNames(state.profile, state.edition).has(name));
88
+ const commands = [/npm (?:ci|install)/u, ...scripts.map(scriptCommand)];
89
+ if (commands.some(command => !command.test(development))) {
90
+ addFinding(state, 'HFS_README_DEVELOPMENT_INCOMPLETE', 'README.md', 'Development must show npm install and the managed script commands: '
91
+ + scripts.map(name => (name === 'test' ? 'npm test' : `npm run ${name}`)).join(', ') + '.');
92
+ }
93
+ }
94
+
95
+ function checkReadmeWorkPointer(state, lines, headings) {
96
+ if (state.tree.hasDir('.starciwork') && headings.some(item => item.name === 'Work') &&
97
+ !/\.starciwork\b/u.test(readmeSectionBody(lines, headings, 'Work')))
98
+ addFinding(state, 'HFS_README_WORK_POINTER_MISSING', 'README.md', 'Work must point at the backend .starciwork tree.');
99
+ }
100
+
101
+ function inspectReadmeUrl(state, value, line) {
102
+ try {
103
+ if (privateHost(new URL(value).hostname))
104
+ addFinding(state, 'HFS_README_PRIVATE_URL', 'README.md', `README URL ${value} points at a local or private host.`, line);
105
+ } catch { /* Malformed URLs are outside this presentation rule. */ }
106
+ }
107
+
108
+ function badgeTargets(prose) {
109
+ return [
110
+ ...[...prose.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gu)].map(match => ({ alt: match[1], target: match[2] })),
111
+ ...[...prose.matchAll(/<img\b[^>]*>/giu)].map(match => ({
112
+ alt: /\balt=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
113
+ target: /\bsrc=["']([^"']*)["']/iu.exec(match[0])?.[1] ?? '',
114
+ })),
115
+ ];
116
+ }
117
+
118
+ function inspectBadgeTarget(state, alt, rawTarget, line) {
119
+ const target = rawTarget.trim().replace(/^<|>$/gu, '');
120
+ if (!/badge/iu.test(alt) && !/badge|shields\.io|badgen\.net/iu.test(target)) return;
121
+ try {
122
+ const url = new URL(target);
123
+ if (url.protocol !== 'https:' || privateHost(url.hostname) ||
124
+ /^(?:img\.shields\.io|badgen\.net)$/iu.test(url.hostname) && url.pathname.startsWith('/badge/'))
125
+ addFinding(state, 'HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must represent a live external HTTPS service.`, line);
126
+ } catch { addFinding(state, 'HFS_README_BADGE_NOT_LIVE', 'README.md', `Badge ${target} must use a live external HTTPS service.`, line); }
127
+ }
128
+
129
+ function inspectReadmeLine(state, line, index, scan) {
130
+ if (/^\s*```/u.test(line)) {
131
+ scan.fenced = !scan.fenced;
132
+ return;
133
+ }
134
+ if (scan.fenced) return;
135
+ const prose = line.replace(/`[^`]*`/gu, '');
136
+ for (const match of prose.matchAll(README_URL)) inspectReadmeUrl(state, trimTrailingChars(match[0], '.,;!?'), index + 1);
137
+ for (const badge of badgeTargets(prose)) inspectBadgeTarget(state, badge.alt, badge.target, index + 1);
138
+ }
139
+
140
+ function checkReadmeLines(state, lines) {
141
+ const scan = { fenced: false };
142
+ for (let index = 0; index < lines.length; index += 1) inspectReadmeLine(state, lines[index], index, scan);
143
+ }
144
+
145
+ function presentationResult(state) {
146
+ return { violations: state.violations, coverage: { status: 'checked', source: state.tree.source } };
147
+ }
148
+
149
+ export function checkRepoPresentationImpl({ root, runtime, tree, profile, edition, nonNpmEntries }) {
150
+ const state = { root, runtime, tree, profile, edition, nonNpmEntries, violations: [] };
151
+ checkRootPresentation(state);
152
+ if (!tree.hasFile('README.md')) return presentationResult(state);
153
+ let readme;
154
+ try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); }
155
+ catch {
156
+ addFinding(state, 'HFS_ROOT_ENTRY_MISSING', 'README.md', 'Tracked README.md is not readable.');
157
+ return presentationResult(state);
158
+ }
159
+ const lines = readme.split(/\r?\n/u);
160
+ const headings = checkReadmeHeadings(state, lines);
161
+ checkReadmeDevelopment(state, lines, headings);
162
+ checkReadmeWorkPointer(state, lines, headings);
163
+ checkReadmeLines(state, lines);
164
+ return presentationResult(state);
165
+ }
@@ -9,12 +9,16 @@
9
9
  * must resolve to a route of that app (page.tsx / route.ts under src/app, route groups and [locale] transparent).
10
10
  * Only literals are judged: a computed href is counted as skipped, never guessed at.
11
11
  */
12
+ import { byCodeUnit } from '../../lib/list.mjs';
13
+
12
14
  export const REACHABILITY_RULE_IDS = ['BE_FEATURE_NOT_COMPOSED', 'FE_OWNER_REACHABLE', 'FE_HREF_RESOLVES'];
13
15
 
14
16
  const BE_CAPABILITY_SLOTS = new Set(['be.domain', 'be.integrations', 'be.platform']);
15
17
  const TEST_FILE = /(?:\.(?:spec|test|stories|e2e-spec)\.[cm]?[jt]sx?$)|(?:(?:^|\/)__tests__\/)/;
16
18
  const ROUTE_FILE = /^(?:page|route)\.(?:tsx|ts|jsx|js)$/;
17
19
  const PLACEHOLDER = '\u0000';
20
+ const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
21
+ const ESCAPE_MATCH = String.raw`\$&`;
18
22
 
19
23
  /** Breadth-first reach over runtime, non-type-only edges (re-exports included) from `roots`. */
20
24
  function reachFrom(graph, roots) {
@@ -50,11 +54,11 @@ function ownerFiles(graph) {
50
54
  /** The file that stands for an owner in a finding: its public entry when the graph holds one, else its first file. */
51
55
  function entryOf(root, files) {
52
56
  const base = root.replace(/\/$/, '');
53
- return files.find(rel => new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/index\\.[tj]sx?$`).test(rel)) ?? [...files].sort()[0];
57
+ const entry = new RegExp(String.raw`^${base.replace(REGEX_SPECIALS, ESCAPE_MATCH)}/index\.[tj]sx?$`);
58
+ return files.find(rel => entry.test(rel)) ?? [...files].sort(byCodeUnit)[0];
54
59
  }
55
60
 
56
- function checkBackend(graph) {
57
- const byUnit = ownerFiles(graph);
61
+ function backendAppRoots(byUnit) {
58
62
  const rootFiles = [];
59
63
  const appRoots = new Set();
60
64
  for (const [key, files] of byUnit) {
@@ -63,11 +67,10 @@ function checkBackend(graph) {
63
67
  appRoots.add(key.slice(key.indexOf(':') + 1));
64
68
  for (const rel of files) if (!TEST_FILE.test(rel)) rootFiles.push(rel);
65
69
  }
66
- const roots = [...appRoots].sort();
67
- const counts = { features: 0, modules: 0, appRoots: roots.length, notComposed: 0 };
68
- if (!roots.length) return { violations: [], coverage: { status: 'unavailable', reason: 'no be.app.* owner instance holds a source file, so there is no app root to compose into', ...counts } };
69
- const reached = reachFrom(graph, rootFiles);
70
- const violations = [];
70
+ return { rootFiles, roots: [...appRoots].sort(byCodeUnit) };
71
+ }
72
+
73
+ function reportUncomposedBackendOwners(byUnit, reached, roots, counts, violations) {
71
74
  for (const [key, files] of [...byUnit].sort(([a], [b]) => a.localeCompare(b))) {
72
75
  const slot = key.slice(0, key.indexOf(':'));
73
76
  const root = key.slice(key.indexOf(':') + 1);
@@ -83,10 +86,41 @@ function checkBackend(graph) {
83
86
  message: `${feature ? 'Feature' : 'Module'} ${label} is not composed into any app: no runtime import path leads from ${roots.join(', ')} to it (a type-only import composes nothing), so it is dead code that no process serves.`,
84
87
  });
85
88
  }
89
+ }
90
+
91
+ function checkBackend(graph) {
92
+ const byUnit = ownerFiles(graph);
93
+ const { rootFiles, roots } = backendAppRoots(byUnit);
94
+ const counts = { features: 0, modules: 0, appRoots: roots.length, notComposed: 0 };
95
+ if (!roots.length) return { violations: [], coverage: { status: 'unavailable', reason: 'no be.app.* owner instance holds a source file, so there is no app root to compose into', ...counts } };
96
+ const reached = reachFrom(graph, rootFiles);
97
+ const violations = [];
98
+ reportUncomposedBackendOwners(byUnit, reached, roots, counts, violations);
86
99
  return { violations, coverage: { status: 'checked', ...counts } };
87
100
  }
88
101
 
89
102
  /** Route table of one app: arrays of segments, each {kind:'static'|'dyn'|'catch'|'optcatch', text}. */
103
+ function routeSegmentsOf(parts) {
104
+ const segments = [];
105
+ let usable = true;
106
+ for (const part of parts) {
107
+ if (/^\([^.)][^)]*\)$/.test(part)) continue; // route group
108
+ if (part.startsWith('@')) continue; // parallel slot: no URL segment
109
+ if (part.startsWith('_') || /^\(\.+\)/.test(part)) { usable = false; break; } // private folder, intercepting route
110
+ segments.push(part);
111
+ }
112
+ if (!usable) return null;
113
+ if (segments[0] === '[locale]') segments.shift();
114
+ return segments;
115
+ }
116
+
117
+ function routeSegmentOf(part) {
118
+ if (/^\[\[\.\.\..+\]\]$/.test(part)) return { kind: 'optcatch', text: part };
119
+ if (/^\[\.\.\..+\]$/.test(part)) return { kind: 'catch', text: part };
120
+ if (/^\[.+\]$/.test(part)) return { kind: 'dyn', text: part };
121
+ return { kind: 'static', text: part };
122
+ }
123
+
90
124
  function routeTable(graph, app) {
91
125
  const prefix = `apps/${app}/src/app/`;
92
126
  const routes = [];
@@ -94,22 +128,9 @@ function routeTable(graph, app) {
94
128
  if (!rel.startsWith(prefix)) continue;
95
129
  const parts = rel.slice(prefix.length).split('/');
96
130
  if (!ROUTE_FILE.test(parts.at(-1))) continue;
97
- const segments = [];
98
- let usable = true;
99
- for (const part of parts.slice(0, -1)) {
100
- if (/^\([^.)][^)]*\)$/.test(part)) continue; // route group
101
- if (part.startsWith('@')) continue; // parallel slot: no URL segment
102
- if (part.startsWith('_') || /^\(\.+\)/.test(part)) { usable = false; break; } // private folder, intercepting route
103
- segments.push(part);
104
- }
105
- if (!usable) continue;
106
- if (segments[0] === '[locale]') segments.shift();
107
- routes.push(segments.map(part => {
108
- if (/^\[\[\.\.\..+\]\]$/.test(part)) return { kind: 'optcatch', text: part };
109
- if (/^\[\.\.\..+\]$/.test(part)) return { kind: 'catch', text: part };
110
- if (/^\[.+\]$/.test(part)) return { kind: 'dyn', text: part };
111
- return { kind: 'static', text: part };
112
- }));
131
+ const segments = routeSegmentsOf(parts.slice(0, -1));
132
+ if (segments === null) continue;
133
+ routes.push(segments.map(routeSegmentOf));
113
134
  }
114
135
  return routes;
115
136
  }
@@ -118,7 +139,7 @@ function routeTable(graph, app) {
118
139
  function segmentMatches(hrefSegment, routeSegment) {
119
140
  if (!hrefSegment.includes(PLACEHOLDER)) return routeSegment.kind === 'dyn' || routeSegment.text === hrefSegment;
120
141
  if (routeSegment.kind === 'dyn' || routeSegment.kind === 'catch' || routeSegment.kind === 'optcatch') return true;
121
- const parts = hrefSegment.split(PLACEHOLDER).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
142
+ const parts = hrefSegment.split(PLACEHOLDER).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`));
122
143
  return new RegExp(`^${parts.join('.*')}$`).test(routeSegment.text);
123
144
  }
124
145
 
@@ -147,7 +168,7 @@ function hrefResolves(href, routes) {
147
168
  /** The text of a string-ish expression with PLACEHOLDER for each ${...}; null when it is not a literal we can read. */
148
169
  function literalText(ts, node) {
149
170
  if (!node) return null;
150
- while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || (ts.isNonNullExpression && ts.isNonNullExpression(node))) node = node.expression;
171
+ while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression?.(node)) node = node.expression;
151
172
  if (ts.isStringLiteralLike(node)) return node.text; // includes NoSubstitutionTemplateLiteral
152
173
  if (ts.isTemplateExpression(node)) return node.head.text + node.templateSpans.map(span => PLACEHOLDER + span.literal.text).join('');
153
174
  return null;
@@ -157,28 +178,47 @@ const NAVIGATION_CALLS = new Set(['redirect', 'permanentRedirect']);
157
178
  const ROUTER_METHODS = new Set(['push', 'replace', 'prefetch']);
158
179
 
159
180
  /** Every href-like target in a source file: {node, text|null (null = computed)}. */
181
+ function jsxHrefTarget(ts, node) {
182
+ if (!ts.isJsxAttribute(node) || !ts.isIdentifier(node.name) || node.name.text !== 'href' || !node.initializer) return null;
183
+ const value = ts.isJsxExpression(node.initializer) ? node.initializer.expression : node.initializer;
184
+ return { node: value ?? node, text: value ? literalText(ts, value) : null };
185
+ }
186
+
187
+ function routerNameOf(ts, callee) {
188
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) return callee.expression.text;
189
+ if (ts.isPropertyAccessExpression(callee) && ts.isPropertyAccessExpression(callee.expression)) return callee.expression.name.text;
190
+ return '';
191
+ }
192
+
193
+ function isNavigationTarget(ts, callee) {
194
+ const routerName = routerNameOf(ts, callee);
195
+ return (ts.isIdentifier(callee) && NAVIGATION_CALLS.has(callee.text))
196
+ || (ts.isPropertyAccessExpression(callee) && ROUTER_METHODS.has(callee.name.text) && /router$/i.test(routerName));
197
+ }
198
+
199
+ function callHrefTarget(ts, node) {
200
+ if (!ts.isCallExpression(node) || !node.arguments.length) return null;
201
+ const callee = node.expression;
202
+ if (!isNavigationTarget(ts, callee)) return null;
203
+ return { node: node.arguments[0], text: literalText(ts, node.arguments[0]) };
204
+ }
205
+
206
+ function hrefTargetOf(ts, node) {
207
+ return jsxHrefTarget(ts, node) ?? callHrefTarget(ts, node);
208
+ }
209
+
160
210
  function hrefTargets(ts, sourceFile) {
161
211
  const found = [];
162
212
  const visit = node => {
163
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === 'href' && node.initializer) {
164
- const value = ts.isJsxExpression(node.initializer) ? node.initializer.expression : node.initializer;
165
- found.push({ node: value ?? node, text: value ? literalText(ts, value) : null });
166
- } else if (ts.isCallExpression(node) && node.arguments.length) {
167
- const callee = node.expression;
168
- const navigates = (ts.isIdentifier(callee) && NAVIGATION_CALLS.has(callee.text))
169
- || (ts.isPropertyAccessExpression(callee) && ROUTER_METHODS.has(callee.name.text)
170
- && /router$/i.test(ts.isIdentifier(callee.expression) ? callee.expression.text : ts.isPropertyAccessExpression(callee.expression) ? callee.expression.name.text : ''));
171
- if (navigates) found.push({ node: node.arguments[0], text: literalText(ts, node.arguments[0]) });
172
- }
213
+ const target = hrefTargetOf(ts, node);
214
+ if (target) found.push(target);
173
215
  ts.forEachChild(node, visit);
174
216
  };
175
217
  visit(sourceFile);
176
218
  return found;
177
219
  }
178
220
 
179
- function checkFrontend(config, context, graph) {
180
- const ts = context.ts ?? context.loaded.ts;
181
- const byUnit = ownerFiles(graph);
221
+ function appRouteRoots(graph) {
182
222
  const apps = new Map();
183
223
  for (const [rel, node] of graph.files) {
184
224
  if (node.slot !== 'fe.route') continue;
@@ -187,9 +227,10 @@ function checkFrontend(config, context, graph) {
187
227
  if (!apps.has(app)) apps.set(app, []);
188
228
  apps.get(app).push(rel);
189
229
  }
190
- const counts = { pages: 0, mounted: 0, hrefs: 0, hrefsResolved: 0, hrefsSkipped: 0, routes: 0 };
191
- const violations = [];
192
- const reachedByApp = new Map([...apps].map(([app, roots]) => [app, reachFrom(graph, roots)]));
230
+ return apps;
231
+ }
232
+
233
+ function reportUnreachableFrontendFeatures(byUnit, reachedByApp, counts, violations) {
193
234
  for (const [key, files] of [...byUnit].sort(([a], [b]) => a.localeCompare(b))) {
194
235
  if (!key.startsWith('fe.feature:')) continue;
195
236
  const root = key.slice('fe.feature:'.length);
@@ -203,27 +244,52 @@ function checkFrontend(config, context, graph) {
203
244
  message: `${label} is not mounted: no runtime import path leads from a route file under apps/${app}/src/app to it (a type-only import mounts nothing), so the page is never shown.`,
204
245
  });
205
246
  }
206
- for (const app of [...new Set([...apps.keys(), ...[...graph.files.keys()].map(rel => /^apps\/([^/]+)\/src\//.exec(rel)?.[1]).filter(Boolean)])].sort()) {
247
+ }
248
+
249
+ function routeApps(graph, apps) {
250
+ return [...new Set([...apps.keys(), ...[...graph.files.keys()].map(rel => /^apps\/([^/]+)\/src\//.exec(rel)?.[1]).filter(Boolean)])].sort(byCodeUnit);
251
+ }
252
+
253
+ function hrefNeedsSkipping(text, routes) {
254
+ if (text === null || !text.startsWith('/') || text.startsWith('//') || !routes.length) return true;
255
+ const hrefPath = text.split(/[?#]/)[0];
256
+ return /\.[A-Za-z0-9]{1,6}$/.test(hrefPath.split('/').at(-1)); // static asset
257
+ }
258
+
259
+ function reportUnresolvedHref(app, rel, node, target, prefix, violations) {
260
+ const point = node.sourceFile.getLineAndCharacterOfPosition(target.node.getStart(node.sourceFile));
261
+ const shown = target.text.split(PLACEHOLDER).join('${…}');
262
+ violations.push({
263
+ ruleId: 'FE_HREF_RESOLVES', path: rel, line: point.line + 1, column: point.character + 1, app, href: shown,
264
+ message: `href "${shown}" matches no route of app ${app}: no page.tsx or route.ts under ${prefix}app serves that path.`,
265
+ });
266
+ }
267
+
268
+ function inspectAppHrefTargets(app, graph, ts, routes, counts, violations) {
269
+ const prefix = `apps/${app}/src/`;
270
+ for (const [rel, node] of graph.files) {
271
+ if (!rel.startsWith(prefix) || TEST_FILE.test(rel)) continue;
272
+ for (const target of hrefTargets(ts, node.sourceFile)) {
273
+ counts.hrefs += 1;
274
+ if (hrefNeedsSkipping(target.text, routes)) { counts.hrefsSkipped += 1; continue; }
275
+ if (hrefResolves(target.text, routes)) { counts.hrefsResolved += 1; continue; }
276
+ reportUnresolvedHref(app, rel, node, target, prefix, violations);
277
+ }
278
+ }
279
+ }
280
+
281
+ function checkFrontend(config, context, graph) {
282
+ const ts = context.ts ?? context.loaded.ts;
283
+ const byUnit = ownerFiles(graph);
284
+ const apps = appRouteRoots(graph);
285
+ const counts = { pages: 0, mounted: 0, hrefs: 0, hrefsResolved: 0, hrefsSkipped: 0, routes: 0 };
286
+ const violations = [];
287
+ const reachedByApp = new Map([...apps].map(([app, roots]) => [app, reachFrom(graph, roots)]));
288
+ reportUnreachableFrontendFeatures(byUnit, reachedByApp, counts, violations);
289
+ for (const app of routeApps(graph, apps)) {
207
290
  const routes = routeTable(graph, app);
208
291
  counts.routes += routes.length;
209
- const prefix = `apps/${app}/src/`;
210
- for (const [rel, node] of graph.files) {
211
- if (!rel.startsWith(prefix) || TEST_FILE.test(rel)) continue;
212
- for (const target of hrefTargets(ts, node.sourceFile)) {
213
- counts.hrefs += 1;
214
- const text = target.text;
215
- if (text === null || !text.startsWith('/') || text.startsWith('//') || !routes.length) { counts.hrefsSkipped += 1; continue; }
216
- const hrefPath = text.split(/[?#]/)[0];
217
- if (/\.[A-Za-z0-9]{1,6}$/.test(hrefPath.split('/').at(-1))) { counts.hrefsSkipped += 1; continue; } // static asset
218
- if (hrefResolves(text, routes)) { counts.hrefsResolved += 1; continue; }
219
- const point = node.sourceFile.getLineAndCharacterOfPosition(target.node.getStart(node.sourceFile));
220
- const shown = text.split(PLACEHOLDER).join('${…}');
221
- violations.push({
222
- ruleId: 'FE_HREF_RESOLVES', path: rel, line: point.line + 1, column: point.character + 1, app, href: shown,
223
- message: `href "${shown}" matches no route of app ${app}: no page.tsx or route.ts under ${prefix}app serves that path.`,
224
- });
225
- }
226
- }
292
+ inspectAppHrefTargets(app, graph, ts, routes, counts, violations);
227
293
  }
228
294
  return { violations, coverage: { status: 'checked', ...counts } };
229
295
  }