@starci/hfs 3.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -21
  3. package/bin/hfs.mjs +59 -37
  4. package/lint/run.mjs +70 -39
  5. package/package.json +2 -2
  6. package/runtime/engine/admission.mjs +3 -3
  7. package/runtime/engine/ledger-db.mjs +2 -2
  8. package/runtime/engine/machine-db.mjs +90 -9
  9. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  10. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  12. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  13. package/runtime/knowledge/hfs/slots.yaml +193 -128
  14. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  15. package/runtime/modules/kernel/failure-codes.yaml +23 -32
  16. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  17. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  18. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  19. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  20. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  21. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  22. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  23. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  24. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  26. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  27. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  28. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  29. package/runtime/scripts/lib/hfs-check.mjs +156 -141
  30. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  31. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  32. package/runtime/scripts/lib/hfs-rules/deps.mjs +4 -2
  33. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  34. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  35. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  36. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  37. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  38. package/runtime/scripts/lib/language.mjs +11 -1
  39. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  40. package/scaffold/app.mjs +205 -0
  41. package/scaffold/service.mjs +26 -16
  42. package/sync/cli.mjs +1 -1
  43. package/sync/hygiene.mjs +11 -8
  44. package/sync/index.mjs +109 -111
  45. package/sync/managed.mjs +9 -8
  46. package/sync/sonar-key.mjs +20 -22
  47. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
  48. package/templates/app/gitignore +6 -0
  49. package/templates/app/hooks/husky/pre-commit +25 -0
  50. package/templates/app/hooks/husky/pre-push +7 -0
  51. package/templates/app/package-scripts/package.json +22 -0
  52. package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
  53. package/templates/app/skeleton/.editorconfig +15 -0
  54. package/templates/app/skeleton/.gitattributes +2 -0
  55. package/templates/app/skeleton/.nvmrc +1 -0
  56. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  57. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  58. package/templates/app/skeleton/README.md +36 -0
  59. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  60. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  61. package/templates/be/skeleton/.sops.yaml +2 -0
  62. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  63. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  64. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  65. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  66. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  67. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  68. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  69. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  70. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  71. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  72. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  73. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  76. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  77. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  78. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  79. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  80. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  81. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  82. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  83. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  84. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  85. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  86. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  87. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  88. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  89. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  90. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  91. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  95. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  96. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  97. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  98. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  99. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  100. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  101. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  102. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  103. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  107. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  108. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  109. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  110. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  111. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  112. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  113. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  114. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  118. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  119. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  120. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  121. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  122. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  123. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  124. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  125. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  126. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  127. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  128. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  129. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  130. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  131. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  132. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  133. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  134. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  135. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  136. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  137. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  138. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  139. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  140. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  141. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  142. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  143. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  147. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  148. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  149. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  150. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  151. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  152. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  153. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  154. package/sync/skeleton.mjs +0 -76
  155. package/templates/be/gitignore +0 -2
  156. package/templates/be/hooks/husky/pre-commit +0 -13
  157. package/templates/be/hooks/husky/pre-push +0 -6
  158. package/templates/be/package-scripts/package.json +0 -19
  159. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  160. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  161. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  162. package/templates/be/tool-config/prettierignore +0 -8
  163. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
  164. package/templates/fe/gitignore +0 -3
  165. package/templates/fe/hooks/husky/pre-commit +0 -16
  166. package/templates/fe/hooks/husky/pre-push +0 -5
  167. package/templates/fe/package-scripts/package.json +0 -13
  168. package/templates/fe/parts/api-client.ts +0 -44
  169. package/templates/fe/parts/api-outcome.ts +0 -7
  170. package/templates/fe/quality-config/sonar-project.properties +0 -8
  171. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  172. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  173. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  174. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  175. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  176. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  177. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  178. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  179. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  180. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  181. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  182. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  183. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  184. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  185. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  186. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  187. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  188. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  189. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  190. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  191. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  192. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  193. package/templates/fe/tool-config/prettierrc +0 -1
  194. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  195. /package/templates/{be → app}/starciwork.gitignore +0 -0
  196. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  197. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  198. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  199. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -41,10 +41,6 @@ export const HFS_RULE_IDS = [
41
41
  'HFS_WORK_IN_FE',
42
42
  ];
43
43
 
44
- const REQUIRED_COMMON = ['.gitattributes', '.github', '.gitignore', '.husky', 'hfs.json',
45
- 'eslint.config.mjs', 'package-lock.json', 'package.json', 'README.md',
46
- 'sonar-project.properties', 'tsconfig.json'];
47
- const REQUIRED_BACKEND = ['.sops.yaml', '.starcistacks', '.starciwork', 'jest.config.js', 'nest-cli.json', 'src'];
48
44
  const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
49
45
  const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
50
46
  const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
@@ -57,18 +53,21 @@ const RETIRED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
57
53
  const EXTRA_TEST_CONFIG = /(?:^|\/)(?:jest[.-][^/]*(?:config\.[cm]?[jt]s|\.json)|jest-(?:e2e|int|integration|harness)[^/]*)$/u;
58
54
  const NODE_ENTRIES_SKIPPED = new Set(['node_modules', '.git']);
59
55
 
60
- /** The slot resolver of a repository: the caller's, else the one its hfs.json declares, else the profile's slots with no app declared. */
56
+ /** The slot resolver of a side folder: the caller's, else the side view its app's hfs.json declares, else the profile's slots with no app declared. */
61
57
  function resolverOf(root, profile, given) {
62
58
  if (given) return given;
63
59
  try { return openHfs({ repoRoot: root }); } catch { /* an invalid hfs.json is HFS_DECLARATION_INVALID's finding, judged elsewhere */ }
64
- return createSlotResolver(loadSlotManifest(), { profile, apps: [], optionalSlots: [], connections: [] });
60
+ return createSlotResolver(loadSlotManifest(), { profile, side: profile, apps: [], optionalSlots: [], connections: [], reads: [] });
65
61
  }
66
62
 
67
- /** The first path segment of every slot of the profile that may exist: the repository root entries (contracts/, docs/, src/ ...). */
68
- function slotRootEntries(resolver) {
69
- const roots = new Set(['apps']);
63
+ /**
64
+ * The first path segment of every slot of the scope that may exist: the root entries of a side (contracts/, docs/, src/ ...) or of
65
+ * the app (be/, fe/, .github/ ...). `scope` is the profile whose slots count (the app resolver answers for the sides too).
66
+ */
67
+ function slotRootEntries(resolver, scope) {
68
+ const roots = new Set(scope === 'app' ? [] : ['apps']);
70
69
  for (const slot of resolver.slots()) {
71
- if (slot.presence === 'forbidden') continue;
70
+ if (slot.presence === 'forbidden' || !slot.profiles.includes(scope)) continue;
72
71
  for (const variant of braceVariants(slot.path)) {
73
72
  const first = variant.split('/')[0];
74
73
  if (first && !/[*?<%]/u.test(first)) roots.add(first);
@@ -77,6 +76,11 @@ function slotRootEntries(resolver) {
77
76
  return roots;
78
77
  }
79
78
 
79
+ /** The root entries the scope's required slots name (the first segment of every required path of the scope): hfs.json, be/, .starcistacks/ ... */
80
+ function requiredRootEntries(resolver) {
81
+ return new Set(resolver.requiredPaths().paths.filter((entry) => !entry.side).map((entry) => entry.path.split('/')[0]));
82
+ }
83
+
80
84
  /** The folders directly below src/tests/ that the be.tests.* slots declare (world, fixtures, integration, e2e, contract). */
81
85
  function slotTestChildren(resolver) {
82
86
  const children = new Set();
@@ -145,25 +149,28 @@ function fsFiles(root, relative = '') {
145
149
  return out;
146
150
  }
147
151
 
152
+ /** The tree view of a list of tracked paths (posix, root-relative): what the git view answers, for a list already in hand. */
153
+ export function trackedTreeView(tracked) {
154
+ const files = new Set(tracked);
155
+ return {
156
+ source: 'git',
157
+ top: [...new Set(tracked.map(file => file.split('/')[0]))],
158
+ children: dir => {
159
+ const prefix = `${dir}/`;
160
+ const names = new Set();
161
+ for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
162
+ return [...names];
163
+ },
164
+ hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
165
+ hasFile: file => files.has(file),
166
+ files: () => tracked,
167
+ };
168
+ }
169
+
148
170
  /** Uniform tree view: top entries plus children(dir)/hasDir/hasFile answers over posix relatives. */
149
171
  function treeView(root) {
150
172
  const tracked = gitPaths(root);
151
- if (tracked !== null) {
152
- const files = new Set(tracked);
153
- return {
154
- source: 'git',
155
- top: [...new Set(tracked.map(file => file.split('/')[0]))],
156
- children: dir => {
157
- const prefix = `${dir}/`;
158
- const names = new Set();
159
- for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
160
- return [...names];
161
- },
162
- hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
163
- hasFile: file => files.has(file),
164
- files: () => tracked,
165
- };
166
- }
173
+ if (tracked !== null) return trackedTreeView(tracked);
167
174
  return {
168
175
  source: 'fs',
169
176
  top: fsChildren(root, '.'),
@@ -211,7 +218,7 @@ function scriptCommand(name) {
211
218
  }
212
219
 
213
220
  /** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
214
- export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'be' }) {
221
+ export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'app' }) {
215
222
  const violations = [];
216
223
  const finding = (ruleId, entry, message, line = 1) => violations.push({ ruleId, path: entry, line, column: 1, message });
217
224
  for (const entry of tree.top) {
@@ -333,7 +340,11 @@ function hooksPathNotRedirected({ root, finding }) {
333
340
  return { status: 'checked' };
334
341
  }
335
342
 
336
- function e2eInAutomaticGate({ root, tree, backend, finding }) {
343
+ /**
344
+ * The app root's automatic gates never run integration, e2e or contract: the husky hooks and the root scripts they call, the unit
345
+ * scripts over the be side's jest configuration (`jestConfig`, app-relative), and the workflows that start on push or pull_request.
346
+ */
347
+ function e2eInAutomaticGate({ root, tree, jestConfig, finding }) {
337
348
  const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
338
349
  let pkg = null;
339
350
  try { pkg = JSON.parse(readText(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
@@ -358,18 +369,31 @@ function e2eInAutomaticGate({ root, tree, backend, finding }) {
358
369
  if (runsE2e(command)) finding(rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
359
370
  for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
360
371
  }
361
- // 2. Unit and coverage scripts on a jest repository select the unit project only and exclude src/tests.
362
- if (backend && tree.hasFile('jest.config.js')) {
372
+ // 2. Unit and coverage scripts over the be side's jest configuration select the unit project only and exclude src/tests.
373
+ if (tree.hasFile(jestConfig)) {
363
374
  for (const name of UNIT_RUN_SCRIPTS) {
364
375
  const command = scripts[name];
365
376
  if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
366
377
  finding(rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
367
378
  }
368
- const jestConfig = readText(root, 'jest.config.js') ?? '';
369
- if (/collectCoverageFrom/u.test(jestConfig) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestConfig))
370
- finding(rule, 'jest.config.js', 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
379
+ const jestText = readText(root, jestConfig) ?? '';
380
+ if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
381
+ finding(rule, jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
382
+ }
383
+ // 3. A workflow that starts on push or pull_request never runs e2e.
384
+ for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
385
+ const text = readText(root, file);
386
+ if (text === null) continue;
387
+ const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
388
+ if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
389
+ if (runsE2e(withoutComments(text)))
390
+ finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
371
391
  }
372
- // 3. A back end's default tsconfig excludes the world, integration, e2e and contract trees.
392
+ }
393
+
394
+ /** The be side's default tsconfig excludes the world, integration, e2e and contract trees (they are checked by src/tests/tsconfig.json). */
395
+ function testTreesOutOfDefaultProgram({ root, tree, finding }) {
396
+ const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
373
397
  const tsconfigText = readText(root, 'tsconfig.json');
374
398
  let tsconfig = null;
375
399
  try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
@@ -378,19 +402,10 @@ function e2eInAutomaticGate({ root, tree, backend, finding }) {
378
402
  const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
379
403
  const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
380
404
  const files = tree.files();
381
- if (backend && files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
405
+ if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
382
406
  (defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
383
407
  finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
384
408
  }
385
- // 4. A workflow that starts on push or pull_request never runs e2e.
386
- for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
387
- const text = readText(root, file);
388
- if (text === null) continue;
389
- const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
390
- if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
391
- if (runsE2e(withoutComments(text)))
392
- finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
393
- }
394
409
  }
395
410
 
396
411
  export function checkHfs(config) {
@@ -401,29 +416,27 @@ export function checkHfs(config) {
401
416
  const tree = treeView(config.root);
402
417
  const violations = [];
403
418
  const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
404
- const resolver = resolverOf(config.root, backend ? 'be' : 'fe', config.hfs);
405
- const presentation = checkRepoPresentation({ root: config.root, tree, profile: backend ? 'be' : 'fe' });
406
- violations.push(...presentation.violations);
407
-
408
- const allowed = slotRootEntries(resolver);
419
+ const profile = backend ? 'be' : 'fe';
420
+ const resolver = resolverOf(config.root, profile, config.hfs);
421
+ // The README, the hooks, the workflows and the scripts are the app root's (checkAppRoot); a side folder is judged as the old
422
+ // repository root it stands for, less those.
423
+ const allowed = slotRootEntries(resolver, profile);
409
424
  for (const entry of [...tree.top].sort()) {
410
425
  if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
411
426
  if (frontend && !backend) {
412
- if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'A frontend repository must not hold a .starciwork tree; Work records live in the backend repository.'); continue; }
413
- if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, 'A frontend repository must not hold .starcistacks; stack declarations live in the backend repository.'); continue; }
414
- if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'A frontend repository keeps source only under apps/<app>/src; the root src/ tree must move.'); continue; }
427
+ if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); continue; }
428
+ if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, 'The fe side must not hold .starcistacks; stack declarations live in be/.starcistacks.'); continue; }
429
+ if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); continue; }
415
430
  }
416
431
  if (frontend && !backend && (isFeTestPath(entry) || isFeTestPath(`${entry}/x`))) continue; // a test entry of a front end is FE_NO_TESTS's, the one finding of that path
417
432
  if (!allowed.has(entry)) {
418
- finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the HFS ${backend ? 'backend' : 'frontend'} allowlist.`);
433
+ finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
419
434
  }
420
435
  }
421
436
 
422
- const required = new Set(REQUIRED_COMMON);
423
- if (backend) for (const entry of REQUIRED_BACKEND) required.add(entry);
424
- for (const entry of [...required].sort()) {
425
- if (entry === 'apps' || entry === 'README.md' || entry === '.gitattributes') continue;
426
- if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${backend ? 'backend' : 'frontend'} HFS tree requires root entry ${entry}.`);
437
+ for (const entry of [...requiredRootEntries(resolver)].sort()) {
438
+ if (entry === 'apps') continue;
439
+ if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${profile} side requires root entry ${entry}.`);
427
440
  }
428
441
 
429
442
  const apps = tree.children('apps').sort();
@@ -444,8 +457,9 @@ export function checkHfs(config) {
444
457
  }
445
458
  // next-env.d.ts is generated by Next and commonly ignored by Git; it is not
446
459
  // a reliable tracked-tree input.
447
- if (frontend) for (const entry of ['package.json', 'next.config.ts', 'tsconfig.json', 'postcss.config.mjs']) {
448
- if (!tree.hasFile(`apps/${app}/${entry}`)) missing.push(entry);
460
+ // A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
461
+ if (frontend) for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
462
+ if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
449
463
  }
450
464
  if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
451
465
  }
@@ -481,15 +495,13 @@ export function checkHfs(config) {
481
495
  finding('HFS_TEST_KIND_RETIRED', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
482
496
  }
483
497
 
484
- e2eInAutomaticGate({ root: config.root, tree, backend, finding });
485
- const hooksPath = hooksPathNotRedirected({ root: config.root, finding });
498
+ if (backend) testTreesOutOfDefaultProgram({ root: config.root, tree, finding });
486
499
 
487
500
  return {
488
501
  violations,
489
502
  coverage: {
490
503
  status: 'checked',
491
504
  source: tree.source,
492
- hooksPath,
493
505
  rootEntries: tree.top.length,
494
506
  apps,
495
507
  ruleIds: [...HFS_RULE_IDS],
@@ -497,6 +509,30 @@ export function checkHfs(config) {
497
509
  };
498
510
  }
499
511
 
512
+ /**
513
+ * The HFS tree check of the app root (`resolver` is the app's): the README, the root entries the app-root slots allow and require,
514
+ * the automatic gates (hooks, the root scripts they call, the workflows) and the hooks path. `hfs check` runs it once per app; the
515
+ * machine runs checkHfs once per side folder.
516
+ */
517
+ export function checkAppRoot({ root, resolver, tree = treeView(root) }) {
518
+ const violations = [];
519
+ const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
520
+ violations.push(...checkRepoPresentation({ root, tree, profile: 'app' }).violations);
521
+ const allowed = slotRootEntries(resolver, 'app');
522
+ for (const entry of [...tree.top].sort()) {
523
+ if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
524
+ if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the slots of the app root.`);
525
+ }
526
+ for (const entry of [...requiredRootEntries(resolver)].sort()) {
527
+ if (entry === 'README.md' || entry === '.gitattributes') continue; // checkRepoPresentation reports these two
528
+ if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The app root requires root entry ${entry}.`);
529
+ }
530
+ const jestConfig = resolver.sides ? `be/${[...braceVariants(resolver.slot('be.tool-config').path)].find(file => file.startsWith('jest.config.'))}` : null;
531
+ e2eInAutomaticGate({ root, tree, jestConfig, finding });
532
+ const hooksPath = hooksPathNotRedirected({ root, finding });
533
+ return { violations, coverage: { status: 'checked', source: tree.source, hooksPath, rootEntries: tree.top.length } };
534
+ }
535
+
500
536
  /** Keep tree findings visible even when the TypeScript architecture config is invalid. */
501
537
  export function checkHfsWithoutConfig(repositoryRoot) {
502
538
  let root;
@@ -505,9 +541,11 @@ export function checkHfsWithoutConfig(repositoryRoot) {
505
541
  }
506
542
  let kinds = [];
507
543
  try {
508
- const authored = JSON.parse(fs.readFileSync(path.resolve(root, 'hfs.json'), 'utf8'));
509
- if (authored.profile === 'be') kinds = ['backend'];
510
- else if (authored.profile === 'fe') kinds = ['frontend'];
544
+ const opened = openHfs({ repoRoot: root });
545
+ // The app root is judged by checkAppRoot; a side folder by its side's tree check.
546
+ if (opened.repo.profile === 'app') return checkAppRoot({ root, resolver: opened });
547
+ if (opened.repo.profile === 'be') kinds = ['backend'];
548
+ else if (opened.repo.profile === 'fe') kinds = ['frontend'];
511
549
  } catch { /* A malformed hfs.json remains an HFS_DECLARATION_INVALID error. */ }
512
550
  if (!kinds.length) {
513
551
  try {
@@ -37,7 +37,8 @@ function relativeSource(repository, value, label) {
37
37
  }
38
38
 
39
39
  function parseContract(config) {
40
- const manifest = JSON.parse(fs.readFileSync(path.join(config.root, 'package.json'), 'utf8'));
40
+ // The contract is declared in the app's one package.json (the app root; config.root is the fe side folder).
41
+ const manifest = JSON.parse(fs.readFileSync(path.join(config.packageRoot ?? config.root, 'package.json'), 'utf8'));
41
42
  const next = manifest?.starci?.codePatterns?.next;
42
43
  const value = next?.dataLifecycle;
43
44
  if (value === undefined) return null;
@@ -711,7 +712,7 @@ export function checkFrontendDataLifecycle(config, context) {
711
712
  details: ['package.json#starci.codePatterns.next.dataLifecycle is required when production source selects SWR', ...references.unsupported] } };
712
713
  reasons.push(...references.unsupported);
713
714
  let installed;
714
- try { installed = installedSWR(config.root); } catch (error) {
715
+ try { installed = installedSWR(config.packageRoot ?? config.root); } catch (error) {
715
716
  return { violations, coverage: { status: 'unavailable', ruleIds, details: [String(error.message ?? error)] } };
716
717
  }
717
718
  if (installed.major !== contract.swr.major) reasons.push(`installed swr ${installed.version} does not match declared major ${contract.swr.major}`);
@@ -115,7 +115,7 @@ function frameworkTargets(config, context, checker, localFiles) {
115
115
 
116
116
  function classToken(ts, checker, node, localFiles) {
117
117
  const symbol = valueSymbol(ts, checker, node);
118
- const declarations = symbol?.getDeclarations?.().filter(declaration => ts.isClassDeclaration(declaration)) ?? [];
118
+ const declarations = (symbol?.getDeclarations?.() ?? []).filter(declaration => ts.isClassDeclaration(declaration)) ?? [];
119
119
  const local = declarations.filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName)));
120
120
  const keys = [...new Set(local.map(declaration => `${canonical(declaration.getSourceFile().fileName)}#${declaration.getStart(declaration.getSourceFile())}`))];
121
121
  return { key: keys.length === 1 ? keys[0] : null, external: declarations.length > 0 && local.length === 0 };
@@ -8,7 +8,9 @@
8
8
  * exported `const Y = X` / `const Y = X.y` (a bare identifier or member that resolves to a function,
9
9
  * class, const or enum of the repository), `type Y = X` and `interface Y extends X {}` (no body, no
10
10
  * type arguments, no type parameters): a second name for one declaration. Rename the declaration,
11
- * or import it by its name.
11
+ * or import it by its name. A route file (slot fe.route) binding a declaration to a name Next.js
12
+ * requires of a route segment (generateMetadata, generateStaticParams, ...) is not a second name: the
13
+ * framework fixes that name, so no rename can remove it.
12
14
  * Specs and tests are not in the graph. The public entry of an owner is found the way dead-exports.mjs finds it.
13
15
  */
14
16
  import { canonical } from './config.mjs';
@@ -145,14 +147,23 @@ function declarationAliases(ts, checker, graph, statement) {
145
147
  return found;
146
148
  }
147
149
 
150
+ /** The names Next.js reads from a route segment file (layout, page, route, ...): the framework fixes them. */
151
+ const NEXT_SEGMENT_EXPORTS = new Set([
152
+ 'generateMetadata', 'metadata', 'generateViewport', 'viewport', 'generateStaticParams', 'generateImageMetadata', 'generateSitemaps',
153
+ 'dynamic', 'dynamicParams', 'revalidate', 'fetchCache', 'runtime', 'preferredRegion', 'maxDuration',
154
+ ]);
155
+ const ROUTE_SLOT = 'fe.route';
156
+
148
157
  function aliasReexports({ context, graph }) {
149
158
  const kind = context.ts.SyntaxKind;
150
159
  const violations = [];
151
160
  for (const [rel, node] of graph.files) {
161
+ const frameworkName = name => node.slot === ROUTE_SLOT && NEXT_SEGMENT_EXPORTS.has(name);
152
162
  const point = target => node.sourceFile.getLineAndCharacterOfPosition(target.getStart(node.sourceFile));
153
163
  const checker = context.checkerFor(node.abs);
154
164
  for (const statement of node.sourceFile.statements) {
155
165
  for (const alias of declarationAliases(context.ts, checker, graph, statement)) {
166
+ if (frameworkName(alias.name)) continue;
156
167
  const at = point(alias.node);
157
168
  violations.push({
158
169
  ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1, name: alias.name, aliasOf: alias.of,
@@ -170,7 +181,7 @@ function aliasReexports({ context, graph }) {
170
181
  continue;
171
182
  }
172
183
  for (const element of clause.elements) {
173
- if (!element.propertyName || element.propertyName.text === element.name.text) continue;
184
+ if (!element.propertyName || element.propertyName.text === element.name.text || frameworkName(element.name.text)) continue;
174
185
  const at = point(element);
175
186
  violations.push({
176
187
  ruleId: 'HFS_ALIAS_REEXPORT', path: rel, line: at.line + 1, column: at.character + 1,
@@ -3,25 +3,30 @@ import { allowsFile } from '../../lib/hfs-allows.mjs';
3
3
  import { DEFAULT_ENVIRONMENT, STACKS_DIRECTORY, STATEFUL_KINDS, namesOfService, readStack } from '../../lib/stack-services.mjs';
4
4
 
5
5
  /**
6
- * R47 `test-world-files` (BE_TEST_TOPOLOGY). Two judgements over the test world, read through slots, the repository's own
6
+ * R47 `test-world-files` (BE_TEST_TOPOLOGY). Judgements over the test world, read through slots, the repository's own
7
7
  * stack definition (`.starcistacks/<env>`, scripts/lib/stack-services.mjs) and the world's declaration
8
- * (`test-world.config.ts` of the test-world library), never through a path or a name list:
8
+ * (`test-world.config.ts`, read in the shape @starci/test-world defines), never through a path or a name list:
9
9
  *
10
10
  * 1. Files. `src/tests/world/` is the only test infrastructure location, and it holds only what its slot `allows`
11
11
  * (knowledge/hfs/slots.yaml be.tests.world: global-setup.ts, global-teardown.ts, use-test-world.ts, and fakes/; kit/ is
12
12
  * its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
13
13
  * (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
14
14
  * matched by the slot's own entries through `allowsFile`.
15
- * 2. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world;
16
- * `fakes/<provider>/` holds a network-edge fake of an external SaaS the team does not operate. A `fakes/<provider>/` that
17
- * fakes a stack service (its own name, its image repository or a well-known alias of the image) is refused, except the one
18
- * owner-approved exception: a stack service that is stateless compute needing special hardware or an external model
19
- * (GPU inference, a self-hosted embedding model) and that the config declares in `fakedBy`:
20
- * `fakedBy: { "<stack service>": { fake: "<fakes/ folder>", reason: "<non-empty>" } }`. A stack service of a stateful
21
- * kind (database, cache, identity, storage, mail, queue, search) or with a persistent volume is never accepted, and a
22
- * `fakedBy` entry that names a stack service or a fake folder that does not exist, or has no reason, is refused as stale
23
- * or empty. The config's `stacks` names the stack environments the world runs (default `dev`); the machine reads the
24
- * literal object of the config through the TypeScript AST and nothing else of it.
15
+ * 2. The declaration. `test-world.config.ts` declares the world in ONE form, the named export R89 allows:
16
+ * `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })` (an exported `const` initialized by a
17
+ * `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
18
+ * read) is refused: the world it declares could not be judged.
19
+ * 3. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world; a fake
20
+ * (a `fakes/<provider>/` folder, or a `fakes` entry of the declaration such as `smtp: smtpFake()`) is a network-edge fake
21
+ * of an external SaaS the team does not operate. A fake of a stack service (its own name, its image repository or a
22
+ * well-known alias of the image) is refused, except the one owner-approved exception: a stack service that is stateless
23
+ * compute needing special hardware or an external model (GPU inference, a self-hosted embedding model) whose `stacks` entry
24
+ * declares it faked: `stacks: { "<stack service>": { fakedBy: "<fake>", reason: "<non-empty>" } }`. A stack service of a
25
+ * stateful kind (database, cache, identity, storage, mail, queue, search) or with a persistent volume is never accepted, and
26
+ * an entry that names a stack service or a fake (a `fakes` entry or a `fakes/` folder) that does not exist, or has no
27
+ * reason, is refused as stale or empty. The declaration's `stack` (`".starcistacks/<env>"`) names the stack environment the
28
+ * world runs (default `dev`); the machine reads the literal object of the declaration through the TypeScript AST and
29
+ * nothing else of it.
25
30
  */
26
31
  export const TEST_WORLD_FILES_RULE_IDS = ['BE_TEST_TOPOLOGY'];
27
32
 
@@ -29,6 +34,8 @@ const RULE = 'BE_TEST_TOPOLOGY';
29
34
  const WORLD_SLOT = 'be.tests.world';
30
35
  const FAKES_DIRECTORY = 'fakes/';
31
36
  const CONFIG_FILE = 'test-world.config.ts';
37
+ const DEFINE = 'defineTestWorld';
38
+ const NAMED_FORM = `export const { useTestWorld, useSandbox } = ${DEFINE}({ ... })`;
32
39
  const defaultEnvironment = DEFAULT_ENVIRONMENT;
33
40
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
34
41
 
@@ -39,51 +46,70 @@ const unwrap = (ts, node) => {
39
46
  return current;
40
47
  };
41
48
  const propertyOf = (ts, literal, key) => literal.properties.find(property => ts.isPropertyAssignment(property) && nameOf(ts, property.name) === key)?.initializer ?? null;
49
+ const isExported = (ts, statement) => (statement.modifiers ?? []).some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
50
+ const positionOf = (sourceFile, node) => {
51
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
52
+ return { line: position.line + 1, column: position.character + 1 };
53
+ };
54
+
55
+ /** The object literal of `defineTestWorld(<object literal>)`, or null. */
56
+ function defineLiteral(ts, expression) {
57
+ const call = unwrap(ts, expression);
58
+ if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== DEFINE) return null;
59
+ const [first] = call.arguments;
60
+ const literal = first ? unwrap(ts, first) : null;
61
+ return literal && ts.isObjectLiteralExpression(literal) ? literal : null;
62
+ }
42
63
 
43
- /** The object literal a config file declares: `export default { ... }` or `export default defineTestWorld({ ... })`. */
64
+ /** The declaration of the world: the object literal of an exported `const` initialized by `defineTestWorld({ ... })`, or null. */
44
65
  function configLiteral(ts, sourceFile) {
45
66
  for (const statement of sourceFile.statements) {
46
- if (!ts.isExportAssignment(statement)) continue;
47
- const expression = unwrap(ts, statement.expression);
48
- if (ts.isObjectLiteralExpression(expression)) return expression;
49
- if (ts.isCallExpression(expression)) {
50
- const [first] = expression.arguments;
51
- const literal = first ? unwrap(ts, first) : null;
52
- if (literal && ts.isObjectLiteralExpression(literal)) return literal;
67
+ if (!ts.isVariableStatement(statement) || !isExported(ts, statement)) continue;
68
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
69
+ for (const declaration of statement.declarationList.declarations) {
70
+ const literal = declaration.initializer ? defineLiteral(ts, declaration.initializer) : null;
71
+ if (literal) return literal;
53
72
  }
54
73
  }
55
74
  return null;
56
75
  }
57
76
 
58
- /** `stacks` as a list of environment names: an array of strings, or the keys of an object literal. */
59
- function stacksOf(ts, literal) {
60
- const node = literal ? propertyOf(ts, literal, 'stacks') : null;
77
+ /** `stack` as the environment it names: the last segment of `".starcistacks/<env>"`; null when absent or not a literal. */
78
+ function environmentOf(ts, literal) {
79
+ const node = literal ? propertyOf(ts, literal, 'stack') : null;
61
80
  const value = node ? unwrap(ts, node) : null;
62
- if (!value) return null;
63
- if (ts.isArrayLiteralExpression(value)) return value.elements.filter(element => ts.isStringLiteralLike(element)).map(element => element.text);
64
- if (ts.isObjectLiteralExpression(value)) return value.properties.map(property => nameOf(ts, property.name)).filter(Boolean);
65
- return null;
81
+ if (!value || !ts.isStringLiteralLike(value)) return null;
82
+ return value.text.split(/[\\/]+/u).filter(Boolean).at(-1) ?? null;
66
83
  }
67
84
 
68
- /** `fakedBy` entries: [{service, fake, reason, line, column}]; a value the reader cannot read statically has an empty fake and reason. */
85
+ /** The keys of the declaration's `fakes` object literal, with their positions. */
86
+ function fakeEntriesOf(ts, sourceFile, literal) {
87
+ const node = literal ? propertyOf(ts, literal, 'fakes') : null;
88
+ const value = node ? unwrap(ts, node) : null;
89
+ if (!value || !ts.isObjectLiteralExpression(value)) return [];
90
+ return value.properties
91
+ .map(property => ({ name: nameOf(ts, property.name), ...positionOf(sourceFile, property) }))
92
+ .filter(entry => entry.name);
93
+ }
94
+
95
+ /** The `stacks` entries that declare `fakedBy`: [{service, fake, reason, line, column}]; a value the reader cannot read statically has an empty fake and reason. */
69
96
  function fakedByOf(ts, sourceFile, literal) {
70
- const node = literal ? propertyOf(ts, literal, 'fakedBy') : null;
97
+ const node = literal ? propertyOf(ts, literal, 'stacks') : null;
71
98
  const value = node ? unwrap(ts, node) : null;
72
99
  if (!value || !ts.isObjectLiteralExpression(value)) return [];
73
100
  const entries = [];
74
101
  for (const property of value.properties) {
75
102
  if (!ts.isPropertyAssignment(property)) continue;
76
- const service = nameOf(ts, property.name);
77
103
  const body = unwrap(ts, property.initializer);
78
- const fake = ts.isObjectLiteralExpression(body) ? propertyOf(ts, body, 'fake') : null;
79
- const reason = ts.isObjectLiteralExpression(body) ? propertyOf(ts, body, 'reason') : null;
80
- const position = sourceFile.getLineAndCharacterOfPosition(property.getStart(sourceFile));
104
+ if (!body || !ts.isObjectLiteralExpression(body)) continue;
105
+ const fake = propertyOf(ts, body, 'fakedBy');
106
+ if (!fake) continue;
107
+ const reason = propertyOf(ts, body, 'reason');
81
108
  entries.push({
82
- service,
83
- fake: fake && ts.isStringLiteralLike(fake) ? fake.text : '',
109
+ service: nameOf(ts, property.name),
110
+ fake: ts.isStringLiteralLike(fake) ? fake.text : '',
84
111
  reason: reason && ts.isStringLiteralLike(reason) ? reason.text.trim() : '',
85
- line: position.line + 1,
86
- column: position.character + 1,
112
+ ...positionOf(sourceFile, property),
87
113
  });
88
114
  }
89
115
  return entries;
@@ -123,7 +149,13 @@ export function checkTestWorldFiles(input) {
123
149
  const configPath = worldRoot === null ? null : `${worldRoot}${CONFIG_FILE}`;
124
150
  const configFile = configPath === null ? null : graph.files.get(configPath) ?? null;
125
151
  const literal = configFile ? configLiteral(ts, configFile.sourceFile) : null;
126
- const environments = (literal ? stacksOf(ts, literal) : null) ?? [defaultEnvironment];
152
+ if (configFile && !literal) {
153
+ violations.push({
154
+ ruleId: RULE, path: configPath, line: 1, column: 1, slot: WORLD_SLOT,
155
+ message: `${configPath} declares no world the machine can read. Declare it in the one named form \`${NAMED_FORM}\` (an exported const of a ${DEFINE} call with an object literal, imported from @starci/test-world); a default export is refused by R89 and is not read.`,
156
+ });
157
+ }
158
+ const environments = [(literal ? environmentOf(ts, literal) : null) ?? defaultEnvironment];
127
159
  const services = [];
128
160
  for (const environment of environments) {
129
161
  let stack = null;
@@ -132,14 +164,15 @@ export function checkTestWorldFiles(input) {
132
164
  }
133
165
  const stackHint = `${STACKS_DIRECTORY}/${environments.join(', ')}`;
134
166
  const statefulWhy = service => (STATEFUL_KINDS.has(service.kind) ? `a ${service.kind} holds data the app reads back` : service.persistent ? 'the stack gives it a persistent volume' : null);
135
- const declared = configFile ? fakedByOf(ts, configFile.sourceFile, literal) : [];
167
+ const declared = literal ? fakedByOf(ts, configFile.sourceFile, literal) : [];
168
+ const fakeEntries = literal ? fakeEntriesOf(ts, configFile.sourceFile, literal) : [];
136
169
 
137
170
  for (const entry of declared) {
138
171
  const service = services.find(candidate => candidate.name === entry.service);
139
172
  const problems = [];
140
173
  if (!service) problems.push(`${stackHint} declares no service ${entry.service}`);
141
174
  else if (statefulWhy(service)) problems.push(`${service.name} is stateful (${statefulWhy(service)}) and always runs real`);
142
- if (!fakeProviders.has(entry.fake)) problems.push(`there is no fakes/${entry.fake || '<fake>'}/ folder`);
175
+ if (!fakeProviders.has(entry.fake) && !fakeEntries.some(fake => fake.name === entry.fake)) problems.push(`there is no fakes entry and no fakes/${entry.fake || '<fake>'}/ folder of that name`);
143
176
  if (entry.reason === '') problems.push('the reason is empty');
144
177
  if (problems.length > 0) {
145
178
  violations.push({
@@ -149,14 +182,19 @@ export function checkTestWorldFiles(input) {
149
182
  }
150
183
  }
151
184
 
152
- for (const [provider, file] of [...fakeProviders].sort(([a], [b]) => a.localeCompare(b))) {
153
- const service = services.find(candidate => namesOfService(candidate).includes(provider));
185
+ // Every fake the world runs: the repository's fakes/<provider>/ folders and the fakes entries of the declaration.
186
+ const fakes = [
187
+ ...[...fakeProviders].map(([provider, file]) => ({ name: provider, path: file, line: 1, column: 1, what: `fakes/${provider}/` })),
188
+ ...fakeEntries.filter(entry => !fakeProviders.has(entry.name)).map(entry => ({ name: entry.name, path: configPath, line: entry.line, column: entry.column, what: `the fakes entry ${entry.name}` })),
189
+ ].sort((a, b) => a.name.localeCompare(b.name));
190
+ for (const fake of fakes) {
191
+ const service = services.find(candidate => namesOfService(candidate).includes(fake.name));
154
192
  if (!service) continue;
155
- if (declared.some(entry => entry.service === service.name && entry.fake === provider)) continue; // judged on its config entry above
193
+ if (declared.some(entry => entry.service === service.name && entry.fake === fake.name)) continue; // judged on its stacks entry above
156
194
  const why = statefulWhy(service);
157
195
  violations.push({
158
- ruleId: RULE, path: file, line: 1, column: 1, slot: WORLD_SLOT,
159
- message: `fakes/${provider}/ fakes ${service.name} (${service.image}), which ${stackHint} declares: a service of the repository's own stack runs real in the test world. ${why ? `${service.name} is stateful (${why}), so no exception applies. ` : `Only stateless compute that needs special hardware or an external model may be faked, and only when ${configPath ?? `${CONFIG_FILE} in the world`} declares it in fakedBy with a reason. `}Delete the fake and let the world run the real service; fakes/ is otherwise only for external SaaS the team does not operate.`,
196
+ ruleId: RULE, path: fake.path, line: fake.line, column: fake.column, slot: WORLD_SLOT,
197
+ message: `${fake.what} fakes ${service.name} (${service.image}), which ${stackHint} declares: a service of the repository's own stack runs real in the test world. ${why ? `${service.name} is stateful (${why}), so no exception applies. ` : `Only stateless compute that needs special hardware or an external model may be faked, and only when ${configPath ?? `${CONFIG_FILE} in the world`} declares it in its stacks entry ({ fakedBy, reason }). `}Delete the fake and let the world run the real service; fakes are otherwise only for external SaaS the team does not operate.`,
160
198
  });
161
199
  }
162
200
  return { violations, coverage: { status: 'checked', files } };