@starci/hfs 4.0.9 → 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 (247) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/LICENSE +21 -0
  3. package/emit/operations.mjs +1 -1
  4. package/emit/type-schema.mjs +2 -1
  5. package/lint/run.mjs +82 -8
  6. package/package.json +3 -2
  7. package/report/order.mjs +2 -0
  8. package/runtime/config.example.yaml +187 -0
  9. package/runtime/engine/by-code-unit.mjs +12 -0
  10. package/runtime/engine/config.mjs +247 -273
  11. package/runtime/engine/invalid-config.mjs +5 -5
  12. package/runtime/engine/model-config.mjs +90 -0
  13. package/runtime/engine/orca-config.mjs +3 -1
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +134 -0
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +102 -69
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -1
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  30. package/runtime/modules/models/registry.yaml +25 -397
  31. package/runtime/modules/models/runtimes.yaml +92 -262
  32. package/runtime/modules/models/tiers.yaml +65 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/lib.mjs +6 -0
  37. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  38. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  39. package/runtime/scripts/api/git/lib.mjs +36 -1
  40. package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
  41. package/runtime/scripts/api/process/run-program.mjs +8 -0
  42. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  43. package/runtime/scripts/api/sops/lib.mjs +309 -11
  44. package/runtime/scripts/api/sops/seal.mjs +8 -4
  45. package/runtime/scripts/hfs/allows.mjs +3 -3
  46. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  47. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  48. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  49. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  50. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  51. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  52. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  53. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  54. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  55. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  56. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  57. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  58. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  59. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  60. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  61. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  62. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  63. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  64. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  65. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  66. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  67. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  69. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  70. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  71. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
  72. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  73. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  75. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  76. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  77. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  79. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  80. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  81. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  83. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  84. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  85. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  86. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  87. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  88. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  89. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  90. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  91. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  92. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  93. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  94. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  95. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  96. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  97. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  98. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  99. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  100. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  101. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  102. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  103. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  104. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  105. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  106. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  107. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  108. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  109. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  110. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  111. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  112. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  113. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  114. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  115. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  116. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  117. package/runtime/scripts/hfs/architecture.mjs +3 -4
  118. package/runtime/scripts/hfs/check.mjs +61 -70
  119. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  120. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  121. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  122. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  123. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  124. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  125. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  126. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  127. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  128. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  129. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  130. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  131. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  132. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  133. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  134. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  135. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  136. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  137. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  138. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  139. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  140. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  141. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  142. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  143. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  144. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  145. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  146. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  147. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  148. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  149. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  150. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  151. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  152. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  153. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  154. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  155. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  156. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  157. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  158. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  159. package/runtime/scripts/hfs/secret.mjs +90 -45
  160. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  161. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  162. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  163. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  165. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  166. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  167. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  168. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  169. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  170. package/runtime/scripts/hfs/slots.mjs +72 -693
  171. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  172. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  173. package/runtime/scripts/hfs/tree.mjs +19 -14
  174. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  175. package/runtime/scripts/hfs/view.mjs +1 -1
  176. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  177. package/runtime/scripts/lib/event-contract.mjs +17 -13
  178. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  179. package/runtime/scripts/lib/git.mjs +2 -2
  180. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  181. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  182. package/runtime/scripts/lib/i18n.mjs +3 -2
  183. package/runtime/scripts/lib/in-order.mjs +71 -0
  184. package/runtime/scripts/lib/language.mjs +3 -3
  185. package/runtime/scripts/lib/list.mjs +8 -1
  186. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  187. package/runtime/scripts/lib/path-key.mjs +42 -9
  188. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  189. package/runtime/scripts/lib/regex.mjs +1 -1
  190. package/runtime/scripts/lib/same-text.mjs +1 -1
  191. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  192. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  193. package/runtime/scripts/lib/sops-envelope.mjs +103 -2
  194. package/runtime/scripts/lib/stack-services.mjs +1 -2
  195. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  196. package/runtime/scripts/lib/walk.mjs +6 -1
  197. package/scaffold/add-cli-lite.mjs +2 -1
  198. package/scaffold/add-table.mjs +2 -2
  199. package/scaffold/add.mjs +3 -2
  200. package/scaffold/app.mjs +4 -2
  201. package/scaffold/edition-gate.mjs +25 -25
  202. package/scaffold/lite-exports.mjs +2 -1
  203. package/scaffold/service.mjs +5 -4
  204. package/sync/hygiene.mjs +15 -9
  205. package/sync/index.mjs +7 -3
  206. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  207. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  208. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  209. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  210. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  211. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  212. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  213. package/templates/app/starciwork.gitignore +1 -1
  214. package/templates/be/image/api/Dockerfile +1 -1
  215. package/templates/be/image/cli/Dockerfile +1 -1
  216. package/templates/be/image/worker/Dockerfile +1 -1
  217. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  218. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  219. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  220. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  221. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  222. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  223. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  224. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  225. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  226. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  228. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  229. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  230. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  231. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  232. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  233. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  234. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  235. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  236. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  237. package/templates/fe/image/next/Dockerfile +2 -1
  238. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  239. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  240. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  242. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  243. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  245. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  246. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  247. package/upgrade/index.mjs +9 -2
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { machineKit } from './machine-ast.mjs';
4
4
  import { constructorDependencies } from './constructor-deps.mjs';
5
5
  import { treeOf } from './required-files.mjs';
6
+ import { byCodeUnit } from '../../lib/list.mjs';
6
7
 
7
8
  /**
8
9
  * R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
@@ -22,7 +23,6 @@ import { treeOf } from './required-files.mjs';
22
23
  export const UNIT_SPEC_PROVIDERS_RULE_IDS = ['BE_SPEC_QUALITY'];
23
24
 
24
25
  const RULE = 'BE_SPEC_QUALITY';
25
- const SERVICE_SPEC = /\.service\.spec\.ts$/u;
26
26
  const SPEC_ROOT = /^(?:src|apps)\//u;
27
27
 
28
28
  /** local name -> {name, module} for every named import of a source file. */
@@ -49,6 +49,87 @@ function testingModules(ts, sourceFile) {
49
49
 
50
50
  const literalKey = (ts, property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
51
51
 
52
+ function providerList(ts, call, rel, report) {
53
+ const options = call.arguments[0];
54
+ const property = options && ts.isObjectLiteralExpression(options) ? options.properties.find(item => literalKey(ts, item) === 'providers') : null;
55
+ const list = property && ts.isPropertyAssignment(property) && ts.isArrayLiteralExpression(property.initializer) ? property.initializer : null;
56
+ if (!list) report(call, `${rel}: Test.createTestingModule has no literal \`providers\` array; list the service and each constructor dependency there.`);
57
+ return list;
58
+ }
59
+
60
+ function readProviders(ts, list, rel, nameOf, report) {
61
+ const provided = [];
62
+ let readable = true;
63
+ for (const element of list.elements) {
64
+ if (ts.isIdentifier(element)) { provided.push({ name: nameOf(element), node: element, isClass: true }); continue; }
65
+ if (ts.isObjectLiteralExpression(element)) {
66
+ const keys = element.properties.map(item => literalKey(ts, item));
67
+ const provide = element.properties.find(item => literalKey(ts, item) === 'provide');
68
+ const value = provide && ts.isPropertyAssignment(provide) ? provide.initializer : null;
69
+ if (keys.length === 2 && keys.includes('useValue') && value && ts.isIdentifier(value)) { provided.push({ name: nameOf(value), node: element, isClass: false }); continue; }
70
+ }
71
+ readable = false;
72
+ report(element, `${rel}: a provider is a class or \`{ provide: TOKEN, useValue: double }\` and nothing else (no useClass, useFactory, useExisting, spread or computed token).`);
73
+ }
74
+ return { provided, readable };
75
+ }
76
+
77
+ function serviceClassFor(ts, rel, serviceRel, serviceFile, imports, list, report) {
78
+ const first = list.elements[0];
79
+ const serviceName = first && ts.isIdentifier(first) ? imports.get(first.text) : null;
80
+ const target = serviceName ? path.posix.normalize(path.posix.join(path.posix.dirname(rel), serviceName.module)).replace(/\.[cm]?[jt]s$/u, '') : null;
81
+ const serviceClass = serviceName && target === serviceRel.replace(/\.ts$/u, '')
82
+ ? serviceFile.sourceFile.statements.find(statement => ts.isClassDeclaration(statement) && statement.name?.text === serviceName.name) : null;
83
+ if (!serviceClass) report(first ?? list, `${rel}: the first provider is the service under test, imported from ./${path.posix.basename(serviceRel, '.ts')}.`);
84
+ return serviceClass ? { serviceClass, serviceName } : null;
85
+ }
86
+
87
+ function checkDependencies(kit, rel, service, serviceFile, list, provided, report) {
88
+ const { serviceName, serviceClass } = service;
89
+ const dependencies = constructorDependencies(kit, serviceFile, serviceClass);
90
+ const expected = [];
91
+ for (const dependency of dependencies) {
92
+ if (dependency.kind === 'unresolved') {
93
+ 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.`);
94
+ continue;
95
+ }
96
+ expected.push(dependency.name);
97
+ }
98
+ const remaining = [...expected];
99
+ for (const item of provided.slice(1)) {
100
+ const index = remaining.indexOf(item.name);
101
+ 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.`);
102
+ else remaining.splice(index, 1);
103
+ }
104
+ 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.`);
105
+ }
106
+
107
+ function checkTestingModule({ ts, kit, rel, serviceRel, serviceFile, imports, call, report }) {
108
+ const list = providerList(ts, call, rel, report);
109
+ if (!list) return;
110
+ const nameOf = identifier => imports.get(identifier.text)?.name ?? identifier.text;
111
+ const { provided, readable } = readProviders(ts, list, rel, nameOf, report);
112
+ if (!readable) return;
113
+ const service = serviceClassFor(ts, rel, serviceRel, serviceFile, imports, list, report);
114
+ if (!service) return;
115
+ checkDependencies(kit, rel, service, serviceFile, list, provided, report);
116
+ }
117
+
118
+ function checkServiceSpec({ config, graph, kit, ts, rel, violations }) {
119
+ const abs = path.join(config.root, ...rel.split('/'));
120
+ if (!fs.existsSync(abs)) return false;
121
+ const sourceFile = ts.createSourceFile(abs, fs.readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true);
122
+ const report = (node, message) => violations.push({ ruleId: RULE, path: rel, ...kit.at(rel, sourceFile, node), message });
123
+ const imports = importsOf(ts, sourceFile);
124
+ const serviceRel = rel.replace(/\.spec\.ts$/u, '.ts');
125
+ const serviceFile = graph.files.get(serviceRel);
126
+ if (!serviceFile) { report(sourceFile, `${rel} has no ${path.posix.basename(serviceRel)} beside it; a service spec sits next to the service it tests.`); return true; }
127
+ const modules = testingModules(ts, sourceFile);
128
+ if (!modules.length) { report(sourceFile, `${rel} never calls Test.createTestingModule({ providers: [...] }); build the service from a testing module holding exactly its constructor dependencies.`); return true; }
129
+ for (const call of modules) checkTestingModule({ ts, kit, rel, serviceRel, serviceFile, imports, call, report });
130
+ return true;
131
+ }
132
+
52
133
  export function checkUnitSpecProviders(input) {
53
134
  const { config, graph } = input;
54
135
  const kit = machineKit(input);
@@ -56,67 +137,8 @@ export function checkUnitSpecProviders(input) {
56
137
  const violations = [];
57
138
  let specs = 0;
58
139
  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
- }
140
+ for (const rel of [...tree.files].filter(file => SPEC_ROOT.test(file) && file.endsWith('.service.spec.ts')).sort(byCodeUnit)) {
141
+ if (checkServiceSpec({ config, graph, kit, ts, rel, violations })) specs += 1;
120
142
  }
121
143
  return { violations, coverage: { status: 'checked', specs, files: graph.files.size } };
122
144
  }
@@ -5,7 +5,6 @@
5
5
  // Prints one starci/architecture-check@1 record. Exit 0: ok. Exit 1: violations or errors (a check that
6
6
  // cannot run is an error, never a pass). Exit 2: bad arguments.
7
7
  import path from 'node:path';
8
- import {fileURLToPath} from 'node:url';
9
8
  import {checkArchitecture} from './architecture/index.mjs';
10
9
  import { isMain } from '../lib/is-main.mjs';
11
10
 
@@ -19,11 +18,11 @@ function parseArchitectureArgs(argv) {
19
18
  const value = argv[index];
20
19
  if (value === '--base') {
21
20
  base = argv[++index];
22
- if (!base) throw Error(`--base needs a commit; ${USAGE}`);
23
- } else if (value.startsWith('-') || root !== null) throw Error(`unexpected argument ${value}; ${USAGE}`);
21
+ if (!base) throw new Error(`--base needs a commit; ${USAGE}`);
22
+ } else if (value.startsWith('-') || root !== null) throw new Error(`unexpected argument ${value}; ${USAGE}`);
24
23
  else root = value;
25
24
  }
26
- if (root === null) throw Error(USAGE);
25
+ if (root === null) throw new Error(USAGE);
27
26
  return {root, base};
28
27
  }
29
28
 
@@ -58,7 +58,6 @@ import { skillRoot } from '../../engine/runtime-root.mjs';
58
58
  import { ARCHITECTURE_RULE_IDS, checkArchitecture } from './architecture/index.mjs';
59
59
  import { parseYaml } from '../../engine/yaml.mjs';
60
60
  import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadRuleCatalog, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './slots.mjs';
61
- import { allowsFile } from './allows.mjs';
62
61
  import { RUNTIME_KIND } from './manifest-shape.mjs';
63
62
  import { lsFiles } from '../api/git/ls-files.mjs';
64
63
  import { diff as gitDiff } from '../api/git/diff.mjs';
@@ -71,14 +70,15 @@ import { appRootFindings } from './rules/app-root.mjs';
71
70
  import { frontendFindings } from './rules/frontend-tree.mjs';
72
71
  import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
73
72
  import { repoLocalCheckFindings } from './rules/repo-local-checks.mjs';
74
- import { readJson } from './rules/read.mjs';
75
73
  import { supabaseSecretFindings } from './rules/supabase-secrets.mjs';
76
74
  import { pathFindings } from './path-findings.mjs';
77
75
  import { onLintSurface } from './architecture/surface.mjs';
78
76
  import { checkAppRoot, trackedTreeView } from './architecture/hfs.mjs';
79
77
  import { testTopologyFindings } from './rules/test-topology.mjs';
80
- import { feNoTestsFindings, isFeTestPath } from './rules/fe-no-tests.mjs';
78
+ import { feNoTestsFindings } from './rules/fe-no-tests.mjs';
81
79
  import { editionFindings } from './rules/edition.mjs';
80
+ import { byCodeUnit } from '../lib/list.mjs';
81
+ import { pinFindings } from './pin-findings.mjs';
82
82
  // The database rules (R213-R216) read SQL through a WASM parser, so they are async: `starci app check` calls them beside the other emitters and passes the findings in as `extraFindings`.
83
83
  export { checkDatabase } from './rules/database.mjs';
84
84
 
@@ -106,10 +106,9 @@ export const CHECK_CODES = Object.freeze([
106
106
  ...REFUSAL_CODES,
107
107
  ]);
108
108
  /** Every code `starci app check` can report: its own and every code the architecture machine can emit (derived from the machine's rule id lists). */
109
- export const ALL_CHECK_CODES = Object.freeze([...new Set([...CHECK_CODES, ...ARCHITECTURE_RULE_IDS])].sort());
109
+ export const ALL_CHECK_CODES = Object.freeze([...new Set([...CHECK_CODES, ...ARCHITECTURE_RULE_IDS])].sort(byCodeUnit));
110
110
  const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/;
111
111
  const VAR = /<([a-z][a-z0-9-]*)>/g;
112
- const DEP_SECTIONS = ['dependencies', 'devDependencies'];
113
112
 
114
113
  const refuse = (code, message, details = {}) => { throw new HfsSlotsError(code, message, details); };
115
114
 
@@ -138,30 +137,9 @@ export function trackedFiles(repoRoot) {
138
137
 
139
138
  const fill = (text, bindings) => String(text).replace(VAR, (whole, name) => bindings[name] ?? whole);
140
139
 
141
- const pinnedSpec = (spec, pin) => (spec === pin.version ? null : `declared ${spec}, pinned ${pin.version}`);
142
-
143
140
  /** The pin map of knowledge/hfs/canon-pins.yaml under `root`. */
144
141
  const readPins = (root) => parseYaml(fs.readFileSync(path.join(root, CANON_PINS_FILE), 'utf8'))?.pins ?? {};
145
142
 
146
- function pinFindings({ repoRoot, files, profile, pins, only }) {
147
- const findings = [];
148
- for (const file of files.filter((f) => (f === 'package.json' || f.endsWith('/package.json')) && (!only || only.has(f)))) {
149
- let pkg;
150
- try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
151
- for (const [name, pin] of Object.entries(pins)) {
152
- // The app root's one package.json carries the pins of both sides.
153
- if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
154
- for (const section of DEP_SECTIONS) {
155
- const spec = pkg[section]?.[name];
156
- if (spec === undefined) continue;
157
- const drift = pinnedSpec(spec, pin);
158
- if (drift) findings.push({ code: 'HFS_CANON_PIN_DRIFT', level: 'error', path: file, dependency: name, section, pinned: pin.version, declared: spec, message: `${name} in ${file} ${section}: ${drift}` });
159
- }
160
- }
161
- }
162
- return findings;
163
- }
164
-
165
143
  /** Instances (slot, root, bindings) present in the tracked tree, for every slot that names required files or a minimum. */
166
144
  function instancesOf(resolver, files) {
167
145
  const found = new Map();
@@ -221,7 +199,7 @@ function treeFindings({ repoRoot, resolver }) {
221
199
  const findings = [];
222
200
  const facts = treeFacts(readTree(repoRoot, { isIgnored: inIgnoredSlot }));
223
201
  for (const { path: dir, below } of facts.empty) {
224
- findings.push({ code: 'HFS_EMPTY_DIR', level: 'error', path: dir, below, message: `${dir} has no file below it${below ? ` (nor in its ${below} sub-director${below === 1 ? 'y' : 'ies'})` : ''}; git tracks no empty directory, so it is a leftover` });
202
+ findings.push({ code: 'HFS_EMPTY_DIR', level: 'error', path: dir, below, message: `${dir} has no file below it` + (below ? ` (nor in its ${below} sub-director${below === 1 && 'y' || 'ies'})` : '') + '; git tracks no empty directory, so it is a leftover' });
225
203
  }
226
204
  for (const { path: dir, of, distance } of facts.ghosts) {
227
205
  findings.push({ code: 'HFS_GHOST_TREE', level: 'error', path: dir, of, distance, message: `${dir} is empty and ${distance} edit${distance === 1 ? '' : 's'} from its sibling ${of}: a renamed or misspelt structure that was never removed` });
@@ -237,77 +215,90 @@ function treeFindings({ repoRoot, resolver }) {
237
215
  /** A side-relative path (or null) as an app-relative one. */
238
216
  const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
239
217
 
240
- /**
241
- * The findings of one scope: the app root (profile app; its own files, and the rules of the files only the root holds: the one
242
- * package.json and lockfile, CI, hooks, .starciwork, .starcistacks) or one side (profile be or fe; the side folder is `repoRoot` and every path is
243
- * relative to it, exactly as the standalone repository root was). `files` are the scope's tracked paths; `all` (root only) every
244
- * tracked path of the app, for the rules that read across it (dependency skew, proof commands).
245
- */
246
- function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, scoped, manifest, editionDeclaration = true }) {
247
- const inScope = (file) => !scoped || scoped.has(file);
248
- // A required directory of the root (be/, fe/) is present through the files below it, which are the sides' own.
249
- const trackedSet = new Set(all);
250
- const present = (p) => (p.endsWith('/') ? all.some((f) => f.startsWith(p)) : trackedSet.has(p));
251
- const findings = [];
252
- const isRoot = repo.profile === APP_SCOPE;
253
-
254
- findings.push(...pathFindings({ files: files.filter(inScope), resolver, profile: repo.profile }));
255
-
256
- const required = resolver.requiredPaths();
218
+ function appendRequiredPathFindings(findings, resolver, files, required, present) {
257
219
  const missing = new Set();
258
220
  const missingFile = (slot, p, via) => {
259
221
  const key = `${slot}|${p}`;
260
222
  if (missing.has(key) || present(p)) return;
261
223
  missing.add(key);
262
224
  const app = appOf(p);
263
- findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
225
+ const appNote = app ? ` (app ${app})` : '';
226
+ findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${appNote}, which is not tracked` });
264
227
  };
265
- // The app's own required paths; each side reports its own (the resolver of the app lists them too, with their side).
266
228
  for (const entry of required.paths) if (!entry.side) missingFile(entry.slot, entry.path, entry.via);
267
229
  const instances = instancesOf(resolver, files);
268
230
  for (const instance of instances) for (const p of requiredOf(resolver.slot(instance.slot), instance)) missingFile(instance.slot, p, 'requires');
231
+ return instances;
232
+ }
233
+
234
+ function minimumInstanceFindings(resolver, required, instances) {
235
+ const findings = [];
269
236
  for (const { slot, min, appKind, side } of required.minimums) {
270
237
  if (appKind !== undefined || side) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
271
- const count = instances.filter((i) => i.slot === slot).length;
238
+ const count = instances.filter((instance) => instance.slot === slot).length;
272
239
  if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
273
240
  }
274
- // The runtime repository (kind runtime) is judged here by its tree law only; its own rules run in scripts/hfs/runtime-check.mjs.
275
- if (repo.kind === RUNTIME_KIND) return findings;
276
-
277
- const pins = readPins(root);
278
- findings.push(...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }));
241
+ return findings;
242
+ }
279
243
 
280
- // The tree checks of the rules that read file content or configuration (rules/*): whole-scope, cheap, no tool run.
281
- findings.push(
282
- ...supabaseSecretFindings({ repoRoot, files: files.filter(inScope), resolver, repo }),
283
- ...editionFindings({ repoRoot, files: files.filter(inScope), repo, resolver, withDeclaration: editionDeclaration }),
244
+ function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration }) {
245
+ const findings = [
246
+ ...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }),
247
+ ...supabaseSecretFindings({ repoRoot, files: files.filter((file) => inScope(file)), resolver, repo }),
248
+ ...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, withDeclaration: editionDeclaration }),
284
249
  ...repoLocalCheckFindings({ repoRoot, files }),
285
250
  ...lintSuppressionFindings({ repoRoot, files }),
286
- );
251
+ ];
287
252
  if (isRoot) {
288
253
  findings.push(
289
254
  ...appRootFindings({ repoRoot, files, all, repo, resolver, pins }),
290
- // The tree check of the app root the machine runs per side for a side folder: README, root entries, automatic gates, hooks path.
291
255
  ...checkAppRoot({ root: repoRoot, resolver, tree: trackedTreeView(all) }).violations.map((item) => ({ code: item.ruleId, level: 'error', path: item.path, line: item.line, column: item.column, source: 'machine', message: `${item.path}: ${item.message}` })),
292
256
  );
293
257
  } else if (repo.profile === 'be') {
294
258
  findings.push(...contractFindings({ repoRoot, files, repo, resolver }), ...testTopologyFindings({ repoRoot, files }));
295
259
  } else {
296
- findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter(inScope) }));
260
+ findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter((file) => inScope(file)) }));
297
261
  }
262
+ return findings;
263
+ }
298
264
 
299
- if (!isRoot) {
300
- const soft = resolver.ruleParams().fileLines.soft;
301
- for (const file of files) {
302
- if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
303
- let lines;
304
- try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
305
- if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
306
- }
265
+ function sizeSoftFindings({ repoRoot, resolver, files, inScope }) {
266
+ const findings = [];
267
+ const soft = resolver.ruleParams().fileLines.soft;
268
+ for (const file of files) {
269
+ if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
270
+ let lines;
271
+ try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
272
+ if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
307
273
  }
308
274
  return findings;
309
275
  }
310
276
 
277
+ /**
278
+ * The findings of one scope: the app root (profile app; its own files, and the rules of the files only the root holds: the one
279
+ * package.json and lockfile, CI, hooks, .starciwork, .starcistacks) or one side (profile be or fe; the side folder is `repoRoot` and every path is
280
+ * relative to it, exactly as the standalone repository root was). `files` are the scope's tracked paths; `all` (root only) every
281
+ * tracked path of the app, for the rules that read across it (dependency skew, proof commands).
282
+ */
283
+ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, scoped, manifest, editionDeclaration = true }) {
284
+ const inScope = (file) => !scoped || scoped.has(file);
285
+ // A required directory of the root (be/, fe/) is present through the files below it, which are the sides' own.
286
+ const trackedSet = new Set(all);
287
+ const present = (p) => (p.endsWith('/') ? all.some((f) => f.startsWith(p)) : trackedSet.has(p));
288
+ const isRoot = repo.profile === APP_SCOPE;
289
+ const findings = pathFindings({ files: files.filter(inScope), resolver, profile: repo.profile });
290
+ const required = resolver.requiredPaths();
291
+ const instances = appendRequiredPathFindings(findings, resolver, files, required, present);
292
+ findings.push(...minimumInstanceFindings(resolver, required, instances));
293
+ // The runtime repository (kind runtime) is judged here by its tree law only; its own rules run in scripts/hfs/runtime-check.mjs.
294
+ if (repo.kind === RUNTIME_KIND) return findings;
295
+
296
+ const pins = readPins(root);
297
+ findings.push(...contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration }));
298
+ if (!isRoot) findings.push(...sizeSoftFindings({ repoRoot, resolver, files, inScope }));
299
+ return findings;
300
+ }
301
+
311
302
  /**
312
303
  * The check of one app. `repoRoot` is the app root (a side folder is checked as that side alone). `declaration` overrides hfs.json
313
304
  * (a dry run over an app that has none); `files` overrides git ls-files (specs), app-relative. `only` (a list of app-relative paths)
@@ -386,7 +377,7 @@ function changedSince(repoRoot, base) {
386
377
 
387
378
  /** The machine's violations and errors as findings: each keeps the machine's rule id as its code. */
388
379
  function machineFindings(report) {
389
- const of = (item) => ({ code: item.ruleId, level: 'error', ...(item.path ? { path: item.path } : {}), ...(item.line ? { line: item.line, column: item.column } : {}), source: 'machine', message: `${item.path ? `${item.path}${item.line ? `:${item.line}` : ''}: ` : ''}${item.message}` });
380
+ const of = (item) => ({ code: item.ruleId, level: 'error', ...(item.path ? { path: item.path } : {}), ...(item.line ? { line: item.line, column: item.column } : {}), source: 'machine', message: ((item.path && item.path + (item.line && ':' + item.line || '') + ': ') || '') + item.message });
390
381
  return [...report.errors.map(of), ...report.violations.map(of)];
391
382
  }
392
383
 
@@ -438,7 +429,7 @@ function machineOver({ scope, manifest, machine, changed }) {
438
429
  if (changed) {
439
430
  const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, scope.repoRoot));
440
431
  const mine = changed.files.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
441
- paths = [...new Set(mine.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
432
+ paths = [...new Set(mine.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort(byCodeUnit);
442
433
  if (!paths.length) return { info: { side, status: 'skipped', reason: `no changed source file in ${label}` }, findings: [] };
443
434
  }
444
435
  let report;
@@ -485,7 +476,7 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
485
476
  tier: tier ?? 'none',
486
477
  owner: owner ? { slot: owner.slot, root: owner.root } : null,
487
478
  allowedImports: mayImport,
488
- importRule: mayImport ? manifest.crossOwner : (tier === 'none' ? 'the slot takes no part in import checks' : null),
479
+ importRule: mayImport ? manifest.crossOwner : (tier === 'none' && 'the slot takes no part in import checks' || null),
489
480
  tests: slot.tests,
490
481
  testsMeaning: TEST_KIND[slot.tests],
491
482
  requiredFiles: resolver.requiredFiles(c.path),
@@ -13,18 +13,38 @@
13
13
  // and `starci app check` compares the files with this derivation. Nothing here lists a path of a product: it all comes from slots.yaml.
14
14
  import { braceVariants, globExpression } from '../lib/glob.mjs';
15
15
  import { logicRolesOf } from './manifest-shape.mjs';
16
+ import { byCodeUnit } from '../lib/list.mjs';
17
+ import { trimTrailingSlashes } from './trailing-slashes.mjs';
16
18
 
17
19
  /** The component that holds the shared infrastructure: the platform tier and every capability more than one service app composes. */
18
20
  export const PLATFORM_COMPONENT = 'platform';
19
21
  const SERVICE_APP_KINDS = new Set(['api', 'worker']);
20
- const star = (text) => String(text).replace(/<[^>]+>/g, '*');
21
- const segments = (dir) => dir.replace(/\/+$/, '').split('/').filter(Boolean);
22
+ /** `text` with every `<placeholder>` (a `<`, at least one character, the next `>`) replaced by `*`; a `<` with no closing `>` stays. */
23
+ export const star = (text) => {
24
+ const source = String(text);
25
+ let result = '';
26
+ let copied = 0;
27
+ let open = source.indexOf('<');
28
+ while (open !== -1) {
29
+ const close = source.indexOf('>', open + 1);
30
+ if (close === -1) break;
31
+ if (close === open + 1) {
32
+ open = source.indexOf('<', close);
33
+ continue;
34
+ }
35
+ result += `${source.slice(copied, open)}*`;
36
+ copied = close + 1;
37
+ open = source.indexOf('<', copied);
38
+ }
39
+ return result + source.slice(copied);
40
+ };
41
+ export const segments = (dir, trimSlashes = false) => (trimSlashes ? trimTrailingSlashes(dir) : dir).split('/').filter(Boolean);
22
42
  const isDir = (path) => path.endsWith('/');
23
43
 
24
44
  /** True when directory pattern `inner` lies inside `outer` (an outer `*` segment matches any one inner segment) or is the same. */
25
- function nested(inner, outer) {
26
- const a = segments(inner);
27
- const b = segments(outer);
45
+ export function nested(inner, outer) {
46
+ const a = segments(inner, true);
47
+ const b = segments(outer, true);
28
48
  return a.length >= b.length && b.every((segment, index) => segment === '*' ? true : segment === a[index]);
29
49
  }
30
50
 
@@ -37,6 +57,24 @@ const variantsOf = (slot) => braceVariants(slot.path).map(star);
37
57
  */
38
58
  const beSlots = (manifest, providers) => manifest.slots.filter((slot) => slot.profiles.includes('be') && slot.tracked === 'tracked' && (slot.provider === undefined || providers.includes(slot.provider)));
39
59
 
60
+ function addRequiredPatterns(sonar, required, suffixes) {
61
+ const escapedSuffixes = suffixes.map((suffix) => suffix.replaceAll('-', String.raw`\-`)).join('|');
62
+ const isRole = new RegExp(String.raw`\.(?:${escapedSuffixes})\.ts$`);
63
+ for (const slot of required) {
64
+ for (const entry of [...(slot.requires ?? []), ...(slot.allows ?? [])]) {
65
+ addEntryPatterns(sonar, slot, entry, isRole);
66
+ }
67
+ }
68
+ }
69
+
70
+ function addEntryPatterns(sonar, slot, entry, isRole) {
71
+ if (!entry.endsWith('.ts') || entry.includes('<role>')) return;
72
+ for (const name of braceVariants(entry)) {
73
+ if (isRole.test(star(name).replaceAll('*', 'x'))) continue;
74
+ for (const dir of variantsOf(slot)) sonar.add(`be/${dir}${star(name)}`.replace(/\/\/+/g, '/'));
75
+ }
76
+ }
77
+
40
78
  /** The directory a path pattern lives in (itself when it is one). */
41
79
  const dirOf = (path) => (isDir(path) ? path : path.slice(0, path.lastIndexOf('/') + 1));
42
80
 
@@ -64,31 +102,26 @@ export function coverageScope(manifest, providers = []) {
64
102
  if (!isDir(dir)) throw new Error(`slot ${slot.id} is coverage: required but its path ${slot.path} is not a directory`);
65
103
  return dir;
66
104
  }));
67
- const roots = minimal(requiredDirs).sort();
105
+ const roots = minimal(requiredDirs).sort(byCodeUnit);
68
106
  const nonePaths = none.flatMap((slot) => variantsOf(slot));
69
107
  for (const dir of nonePaths.filter(isDir)) if (requiredDirs.some((other) => nested(other, dir))) throw new Error(`a coverage: none directory ${dir} contains a coverage: required slot`);
70
108
  const noneUnique = [...new Set(nonePaths)];
71
109
  const noneMinimal = noneUnique.filter((path) => !noneUnique.some((other) => other !== path && isDir(other) && nested(dirOf(path), other)));
72
- const excludes = noneMinimal.filter((path) => isDir(path) && roots.some((root) => nested(path, root))).sort();
110
+ const excludes = noneMinimal.filter((path) => isDir(path) && roots.some((root) => nested(path, root))).sort(byCodeUnit);
73
111
  const suffixes = manifest.ruleParams.be.suffixes;
74
- const isRole = new RegExp(`\\.(?:${suffixes.map((s) => s.replace(/-/g, '\\-')).join('|')})\\.ts$`);
75
112
  const sonar = new Set(noneMinimal.map((path) => `be/${path}${isDir(path) ? '**' : ''}`));
76
113
  for (const role of suffixes.filter((suffix) => !roles.includes(suffix))) sonar.add(`be/**/*.${role}.ts`);
77
- for (const slot of required) {
78
- for (const entry of [...(slot.requires ?? []), ...(slot.allows ?? [])]) {
79
- if (!/\.ts$/.test(entry) || entry.includes('<role>')) continue;
80
- for (const name of braceVariants(entry)) {
81
- if (isRole.test(star(name).replace(/\*/g, 'x'))) continue;
82
- for (const dir of variantsOf(slot)) sonar.add(`be/${dir}${star(name)}`.replace(/\/\/+/g, '/'));
83
- }
84
- }
85
- }
114
+ addRequiredPatterns(sonar, required, suffixes);
86
115
  sonar.add('fe/**');
87
- return { roles, roots, excludes, none: noneMinimal.sort(), sonar: [...sonar].sort(), codecovPaths: roots.map((root) => `be/${root}**`) };
116
+ noneMinimal.sort(byCodeUnit);
117
+ return { roles, roots, excludes, none: noneMinimal, sonar: [...sonar].sort(byCodeUnit), codecovPaths: roots.map((root) => `be/${root}**`) };
88
118
  }
89
119
 
90
120
  /** The glob a root measures: every file of a logic role below it (`src/modules/domain/*\/**\/*.{service,policy}.ts`). */
91
- export const rootGlob = (root, roles) => `${root}**/*.${roles.length === 1 ? roles[0] : `{${roles.join(',')}}`}.ts`;
121
+ export const rootGlob = (root, roles) => {
122
+ const roleGlob = roles.length === 1 ? roles[0] : `{${roles.join(',')}}`;
123
+ return `${root}**/*.${roleGlob}.ts`;
124
+ };
92
125
 
93
126
  /** The jest coverage options of the rendered be/jest.config.js: { roots, roles, excludes }. */
94
127
  export function jestCoverage(manifest, providers = []) {
@@ -107,13 +140,17 @@ export const codecovPaths = (manifest, providers = []) => coverageScope(manifest
107
140
  * (`src/modules/domain/*\/` and `src/modules/domain/order/order.service.ts` give `order`).
108
141
  */
109
142
  function capabilityOf(root, file) {
110
- const parts = segments(root);
143
+ const parts = segments(root, true);
111
144
  const at = parts.length - 1;
112
145
  if (parts[at] !== '*') return null;
113
146
  const own = file.split('/');
114
147
  return parts.slice(0, at).every((part, index) => part === own[index]) && own.length > at + 1 ? own[at] : null;
115
148
  }
116
149
 
150
+ function escapeImportPart(part) {
151
+ return part.replace(/[/.]/g, String.raw`\$&`);
152
+ }
153
+
117
154
  /**
118
155
  * The Codecov components of an app, [{ id, name, paths }] (paths repository-relative from the app root): one per service app (api or worker)
119
156
  * that composes the capabilities nothing else composes, then `platform`. A capability of a root whose last segment is a placeholder
@@ -130,28 +167,37 @@ export function coverageComponents(manifest, { files, read, apps, providers = []
130
167
  const imports = new Map(services.map((app) => [app.name, files.filter((file) => file.startsWith(`apps/${app.name}/`) && /\.[cm]?ts$/.test(file)).map((file) => read(file)).join('\n')]));
131
168
  const platformPaths = [];
132
169
  const ownedBy = new Map(services.map((app) => [app.name, []]));
133
- for (const root of roots) {
134
- const module = /^src\/modules\/(.+)\/\*\/$/.exec(root)?.[1] ?? null;
135
- const capabilities = [...new Set(files.map((file) => capabilityOf(root, file)).filter(Boolean))].sort();
136
- const base = root.slice(0, -2);
137
- if (module === null || module === 'platform' || !capabilities.length) {
138
- platformPaths.push(`be/${root}**`);
139
- continue;
140
- }
141
- for (const capability of capabilities) {
142
- const spec = new RegExp(`from\\s+["']@modules/${module.replace(/[/.]/g, '\\$&')}/${capability.replace(/[/.]/g, '\\$&')}["']`);
143
- const importers = services.filter((app) => spec.test(imports.get(app.name)));
144
- const owners = importers.length === 1 ? importers : importers.filter((app) => app.name === capability);
145
- if (owners.length === 1) ownedBy.get(owners[0].name).push(`be/${base}${capability}/**`);
146
- else platformPaths.push(`be/${base}${capability}/**`);
147
- }
148
- }
170
+ for (const root of roots) assignRoot({ root, files, services, imports, platformPaths, ownedBy });
171
+ const components = services.filter((app) => ownedBy.get(app.name).length).map((app) => {
172
+ const paths = ownedBy.get(app.name);
173
+ paths.sort(byCodeUnit);
174
+ return { id: app.name, name: app.name, paths };
175
+ });
176
+ if (platformPaths.length) platformPaths.sort(byCodeUnit);
149
177
  return [
150
- ...services.filter((app) => ownedBy.get(app.name).length).map((app) => ({ id: app.name, name: app.name, paths: ownedBy.get(app.name).sort() })),
151
- ...(platformPaths.length ? [{ id: PLATFORM_COMPONENT, name: PLATFORM_COMPONENT, paths: platformPaths.sort() }] : []),
178
+ ...components,
179
+ ...(platformPaths.length ? [{ id: PLATFORM_COMPONENT, name: PLATFORM_COMPONENT, paths: platformPaths }] : []),
152
180
  ];
153
181
  }
154
182
 
183
+ /** Puts the measured root, or each capability below it, into the platform paths or the paths of the one service app that composes it. */
184
+ function assignRoot({ root, files, services, imports, platformPaths, ownedBy }) {
185
+ const module = /^src\/modules\/(.+)\/\*\/$/.exec(root)?.[1] ?? null;
186
+ const capabilities = [...new Set(files.map((file) => capabilityOf(root, file)).filter(Boolean))].sort(byCodeUnit);
187
+ const base = root.slice(0, -2);
188
+ if (module === null || module === 'platform' || !capabilities.length) {
189
+ platformPaths.push(`be/${root}**`);
190
+ return;
191
+ }
192
+ for (const capability of capabilities) {
193
+ const spec = new RegExp(String.raw`from\s+["']@modules/${escapeImportPart(module)}/${escapeImportPart(capability)}["']`);
194
+ const importers = services.filter((app) => spec.test(imports.get(app.name)));
195
+ const owners = importers.length === 1 ? importers : importers.filter((app) => app.name === capability);
196
+ if (owners.length === 1) ownedBy.get(owners[0].name).push(`be/${base}${capability}/**`);
197
+ else platformPaths.push(`be/${base}${capability}/**`);
198
+ }
199
+ }
200
+
155
201
  /** True when the be-relative `file` is measured: it is `<name>.<role>.ts` of a logic role below a measured root and inside no none directory of it. */
156
202
  export function isMeasured(manifest, file) {
157
203
  const { roots, roles, excludes } = coverageScope(manifest);