@starci/hfs 5.0.8 → 5.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 (152) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/cache/import-graph.mjs +6 -61
  3. package/cache/lint-family.d.mts +10 -1
  4. package/cache/lint-family.mjs +12 -5
  5. package/codemod/directive-first.d.mts +48 -0
  6. package/codemod/directive-first.mjs +136 -0
  7. package/codemod/explicit-surface-edit.d.mts +14 -0
  8. package/codemod/explicit-surface-edit.mjs +57 -0
  9. package/codemod/explicit-surface-names.d.mts +30 -0
  10. package/codemod/explicit-surface-names.mjs +135 -0
  11. package/codemod/explicit-surface-render.d.mts +29 -0
  12. package/codemod/explicit-surface-render.mjs +197 -0
  13. package/codemod/explicit-surface.d.mts +50 -0
  14. package/codemod/explicit-surface.mjs +374 -0
  15. package/codemod/main.d.mts +7 -5
  16. package/codemod/main.mjs +37 -16
  17. package/codemod/source-tree.d.mts +8 -0
  18. package/codemod/source-tree.mjs +23 -0
  19. package/lint/eslint-list.cjs +29 -0
  20. package/lint/run.d.mts +19 -5
  21. package/lint/run.mjs +47 -14
  22. package/package.json +1 -1
  23. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  24. package/runtime/knowledge/hfs/rules.yaml +52 -1
  25. package/runtime/scripts/hfs/architecture/test-world-files.d.mts +4 -3
  26. package/runtime/scripts/hfs/architecture/test-world-files.mjs +26 -7
  27. package/runtime/scripts/hfs/explicit-surface-rule.d.mts +58 -0
  28. package/runtime/scripts/hfs/explicit-surface-rule.mjs +59 -0
  29. package/runtime/scripts/lib/declaration-surface.d.mts +35 -0
  30. package/runtime/scripts/lib/declaration-surface.mjs +112 -0
  31. package/templates/be/inbox/delivery-recorded.event.ts.tpl +2 -2
  32. package/templates/be/inbox/entities.index.ts.tpl +1 -1
  33. package/templates/be/inbox/module-definition.ts.tpl +8 -2
  34. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +10 -2
  35. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +8 -2
  36. package/templates/be/patterns/api/feature.module-definition.ts.tpl +9 -3
  37. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +9 -3
  38. package/templates/be/patterns/cli/group.module-definition.ts.tpl +9 -3
  39. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +8 -3
  40. package/templates/be/patterns/event-bus/platform/persistence/connection.ts.tpl +3 -2
  41. package/templates/be/patterns/event-bus/platform/persistence/event-bus.sql.ts.tpl +4 -3
  42. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +8 -3
  43. package/templates/be/patterns/idempotency/platform/inbox/persistence/connection.ts.tpl +3 -2
  44. package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.sql.ts.tpl +7 -6
  45. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +9 -3
  46. package/templates/be/patterns/jobs/job.processor.ts.tpl +1 -1
  47. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +9 -3
  48. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +8 -3
  49. package/templates/be/patterns/jobs/platform/persistence.connection.ts.tpl +3 -2
  50. package/templates/be/patterns/jobs/platform/persistence.jobs.sql.ts.tpl +5 -4
  51. package/templates/be/patterns/projections/persistence.connection.ts.tpl +3 -2
  52. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +8 -3
  53. package/templates/be/patterns/queues/platform/persistence.connection.ts.tpl +3 -2
  54. package/templates/be/patterns/queues/platform/persistence.queue.sql.ts.tpl +4 -3
  55. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +8 -3
  56. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +9 -3
  57. package/templates/be/patterns/reactors/consumer.ts.tpl +1 -1
  58. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +9 -3
  59. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +9 -3
  60. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +9 -3
  61. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +9 -3
  62. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +9 -3
  63. package/templates/be/patterns/saga/saga.done.consumer.ts.tpl +1 -1
  64. package/templates/be/patterns/saga/saga.failed.consumer.ts.tpl +1 -1
  65. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +9 -3
  66. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +9 -3
  67. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +9 -3
  68. package/templates/be/platform/graphql/graphql.module-definition.ts +8 -3
  69. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +9 -3
  70. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +9 -3
  71. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +9 -3
  72. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +9 -3
  73. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +9 -3
  74. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +8 -3
  75. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +8 -3
  76. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +8 -3
  77. package/templates/be/skeleton/src/modules/domain/note/persistence/connection.ts +3 -2
  78. package/templates/be/skeleton/src/modules/domain/note/persistence/note.sql.ts +2 -1
  79. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +8 -3
  80. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +8 -3
  81. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +8 -3
  82. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  83. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +10 -4
  84. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +8 -4
  85. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +10 -4
  86. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +3 -2
  87. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +3 -2
  88. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +10 -4
  89. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +8 -3
  90. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +8 -3
  91. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +8 -3
  92. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +1 -1
  93. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +5 -1
  94. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +5 -1
  95. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +1 -1
  96. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +1 -1
  97. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +7 -1
  98. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +7 -1
  99. package/templates/be/table/api/feature.module-definition.ts.tpl +9 -3
  100. package/templates/be/table/api/graphql.module-definition.ts.tpl +9 -3
  101. package/templates/be/table/entities.index.ts.tpl +1 -1
  102. package/templates/be/table/module-definition.ts.tpl +8 -3
  103. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +1 -0
  104. package/templates/be/test-world/world/test-world.config.ts.tpl +8 -1
  105. package/templates/fe/common/app-layout.tsx.partial +1 -1
  106. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +2 -1
  107. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +11 -3
  108. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +8 -2
  109. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +2 -1
  110. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +1 -1
  111. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +4 -1
  112. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +3 -2
  113. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +1 -0
  114. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +12 -4
  115. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +8 -3
  116. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +4 -3
  117. package/templates/fe/skeleton/apps/app/src/features/layouts/AppLayout/index.tsx +1 -1
  118. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +2 -1
  119. package/templates/fe/skeleton/apps/app/src/features/pages/AppLoadingPage/index.tsx +2 -1
  120. package/templates/fe/skeleton/apps/app/src/features/pages/AppNotFoundPage/index.tsx +2 -1
  121. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +2 -1
  122. package/templates/fe/skeleton/apps/app/src/modules/i18n/request.ts +4 -1
  123. package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +4 -3
  124. package/templates/fe/skeleton/apps/landing/src/features/layouts/LandingLayout/index.tsx +2 -2
  125. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingLoadingPage/index.tsx +2 -1
  126. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingNotFoundPage/index.tsx +2 -1
  127. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/index.tsx +2 -1
  128. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +2 -1
  129. package/templates/fe/skeleton/apps/landing/src/modules/i18n/request.ts +4 -1
  130. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +19 -1
  131. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +1 -1
  132. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +5 -1
  133. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +4 -3
  134. package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +2 -2
  135. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -1
  136. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppLoadingPage/index.tsx +2 -1
  137. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppNotFoundPage/index.tsx +2 -1
  138. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +3 -1
  139. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +5 -1
  140. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +2 -1
  141. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +2 -1
  142. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  143. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +1 -1
  144. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +1 -1
  145. package/templates/fe/transport/graphql/clients/links/error.ts +1 -1
  146. package/templates/fe/transport/graphql/clients/links/http.ts +1 -1
  147. package/templates/fe/transport/graphql/clients/links/locale.ts +1 -1
  148. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +1 -1
  149. package/templates/fe/transport/graphql/clients/links/retry.ts +1 -1
  150. package/templates/fe/transport/graphql/clients/links/timeout.ts +1 -1
  151. package/templates/fe/units/page/index.tsx.tpl +3 -2
  152. package/templates/fe/units/slot/SlotView.tsx.tpl +1 -1
package/codemod/main.mjs CHANGED
@@ -7,11 +7,13 @@ import { migrateSectionFrame, sectionFrameMarkdown } from './section-frame.mjs';
7
7
  import { migrateSmallRenames, smallRenamesMarkdown } from './small-renames.mjs';
8
8
  import { migrateSpacingRoles, spacingRolesMarkdown } from './spacing-roles.mjs';
9
9
  import { migrateSurfaceCardKinds, surfaceCardKindsMarkdown } from './surface-card-kinds.mjs';
10
+ import { explicitSurfaceMarkdown, migrateExplicitSurface } from './explicit-surface.mjs';
11
+ import { directiveFirstMarkdown, migrateDirectiveFirst } from './directive-first.mjs';
10
12
  import { migrateThemeTokens, themeTokensMarkdown } from './theme-tokens.mjs';
11
13
  import { migrateTierBinding, tierBindingMarkdown } from './tier-binding.mjs';
12
14
  /** The codemods `starci app codemod` runs, by name. */
13
- export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states']);
14
- /** The arguments of `starci app codemod spacing-roles`, `theme-tokens`, `small-renames`, `page-navigation`, `section-frame`, `surface-card-kinds`, `tier-binding` and `loading-states`: --execute, --json, --format and --out; any other is refused. */
15
+ export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states', 'explicit-surface', 'directive-first']);
16
+ /** The arguments of every report-style `starci app codemod`: --execute, --json, --format and --out; any other is refused. */
15
17
  export function spacingRolesArguments(argv) {
16
18
  const options = { write: false, format: 'markdown' };
17
19
  const args = argv.values();
@@ -44,6 +46,10 @@ export function spacingRolesArguments(argv) {
44
46
  }
45
47
  /** One of the report-style codemods run at an app root: its report object and the Markdown of it. */
46
48
  function runCodemod(name, root, write) {
49
+ if (name === 'directive-first') {
50
+ const report = migrateDirectiveFirst({ root, write });
51
+ return { report, markdown: directiveFirstMarkdown(report) };
52
+ }
47
53
  if (name === 'theme-tokens') {
48
54
  const report = migrateThemeTokens({ root, write });
49
55
  return { report, markdown: themeTokensMarkdown(report) };
@@ -75,6 +81,30 @@ function runCodemod(name, root, write) {
75
81
  const report = migrateSpacingRoles({ root, write });
76
82
  return { report, markdown: spacingRolesMarkdown(report) };
77
83
  }
84
+ /** The report written where the options say (`--out`) and to stdout; the exit code is 1 when a case is left for a person. */
85
+ function emit(options, result, io) {
86
+ const output = options.format === 'json' ? `${JSON.stringify(result.report, null, 2)}\n` : result.markdown;
87
+ if (options.out) {
88
+ const target = path.resolve(io.cwd, options.out);
89
+ fs.mkdirSync(path.dirname(target), { recursive: true });
90
+ fs.writeFileSync(target, output);
91
+ }
92
+ io.stdout(output);
93
+ return result.report.findings.length ? 1 : 0;
94
+ }
95
+ /** The arguments of a report-style codemod: spacingRolesArguments, and a report file that is JSON or Markdown. */
96
+ function reportArguments(argv) {
97
+ const options = spacingRolesArguments(argv);
98
+ if (options.out && !/\.(?:json|md)$/i.test(options.out))
99
+ throw new Error('--out needs a .json or .md report file');
100
+ return options;
101
+ }
102
+ /** `starci app codemod explicit-surface`: it reads the app's types through the compiler and formats with its Prettier, both asynchronous. */
103
+ async function explicitSurfaceMain(argv, io) {
104
+ const options = reportArguments(argv);
105
+ const report = await migrateExplicitSurface({ root: path.resolve(io.cwd), write: options.write });
106
+ return emit(options, { report, markdown: explicitSurfaceMarkdown(report) }, io);
107
+ }
78
108
  /**
79
109
  * CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
80
110
  * that leaves a case for a person exits 1 with the complete report.
@@ -83,19 +113,10 @@ export function codemodMain(argv, io) {
83
113
  const [name, ...rest] = argv;
84
114
  if (name === 'button-intent')
85
115
  return buttonIntentMain(argv, io);
86
- if (name !== 'spacing-roles' && name !== 'theme-tokens' && name !== 'small-renames' && name !== 'page-navigation' && name !== 'section-frame' && name !== 'surface-card-kinds' && name !== 'tier-binding' && name !== 'loading-states')
116
+ if (name === 'explicit-surface')
117
+ return explicitSurfaceMain(rest, io);
118
+ if (name === undefined || !CODEMODS.includes(name))
87
119
  throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
88
- const options = spacingRolesArguments(rest);
89
- if (options.out && !/\.(?:json|md)$/i.test(options.out))
90
- throw new Error('--out needs a .json or .md report file');
91
- const root = path.resolve(io.cwd);
92
- const { report, markdown } = runCodemod(name, root, options.write);
93
- const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : markdown;
94
- if (options.out) {
95
- const target = path.resolve(io.cwd, options.out);
96
- fs.mkdirSync(path.dirname(target), { recursive: true });
97
- fs.writeFileSync(target, output);
98
- }
99
- io.stdout(output);
100
- return report.findings.length ? 1 : 0;
120
+ const options = reportArguments(rest);
121
+ return emit(options, runCodemod(name, path.resolve(io.cwd), options.write), io);
101
122
  }
@@ -0,0 +1,8 @@
1
+ /** Folders no codemod enters: installed packages, build output and caches. */
2
+ export declare const GENERATED: Set<string>;
3
+ /** The folders of an app a codemod reads source from: the `be` and `fe` sides, else `src` of a single-side app, as absolute paths. */
4
+ export declare function sideFolders(root: string): string[];
5
+ /** Every file under `directory` whose name matches `pattern`, sorted, without descending into generated folders. */
6
+ export declare function filesUnder(directory: string, pattern: RegExp): string[];
7
+ /** The posix path of `target` relative to `root`, the way a report names a file. */
8
+ export declare const relativePosix: (root: string, target: string) => string;
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Folders no codemod enters: installed packages, build output and caches. */
4
+ export const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'build', 'out', 'coverage', '.git', 'storybook-static']);
5
+ /** The folders of an app a codemod reads source from: the `be` and `fe` sides, else `src` of a single-side app, as absolute paths. */
6
+ export function sideFolders(root) {
7
+ const isFolder = (folder) => fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory() === true;
8
+ const sides = ['be', 'fe'].map(name => path.join(root, name)).filter(isFolder);
9
+ if (sides.length)
10
+ return sides;
11
+ return ['src'].map(name => path.join(root, name)).filter(isFolder);
12
+ }
13
+ /** Every file under `directory` whose name matches `pattern`, sorted, without descending into generated folders. */
14
+ export function filesUnder(directory, pattern) {
15
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
16
+ const target = path.join(directory, entry.name);
17
+ if (entry.isDirectory())
18
+ return GENERATED.has(entry.name) ? [] : filesUnder(target, pattern);
19
+ return entry.isFile() && pattern.test(entry.name) ? [target] : [];
20
+ }).sort();
21
+ }
22
+ /** The posix path of `target` relative to `root`, the way a report names a file. */
23
+ export const relativePosix = (root, target) => path.relative(root, target).split(path.sep).join('/');
@@ -1,4 +1,12 @@
1
1
  "use strict";
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
2
10
  Object.defineProperty(exports, "__esModule", { value: true });
3
11
  // eslint-list.cts - ESLint over a list of files in ONE process, through the app's own install (the Node API of the ESLint the side's folder resolves).
4
12
  //
@@ -7,9 +15,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
15
  // argument ({ files, fix }), lints it once from its working directory (the side folder) and prints the results as the `--format json` of the CLI
8
16
  // does (the list of result objects), so the launcher validates them the same way. Its exit code is the CLI's: 0 clean of errors, 1 at least one
9
17
  // error, 2 the run could not complete (the message is on stderr). It stays CommonJS like bound-sys.cts, which may be preloaded in front of it.
18
+ //
19
+ // The project graph the canon's graph rules read is built in this process, once per side. When the job names the canons (`graph.canons`, the entry file
20
+ // of each installed canon) and a file (`graph.out`), the runner asks each canon for the snapshot of the graph it built (`projectGraphSnapshot`) after
21
+ // the lint and writes `{ snapshots }` there, so the cache keeps the graph findings without building the graph a second time in the CLI. A canon that
22
+ // cannot answer leaves its snapshot out; the report and the exit code are never affected.
10
23
  const fs = require('node:fs');
11
24
  const path = require('node:path');
12
25
  const { createRequire } = require('node:module');
26
+ const { pathToFileURL } = require('node:url');
27
+ /** Writes the snapshot of the graph each named canon built in this process; a canon that cannot answer is left out. */
28
+ async function handOutGraph(handoff, repoRoot) {
29
+ const snapshots = {};
30
+ for (const { name, entry } of handoff.canons) {
31
+ try {
32
+ const canon = await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
33
+ if (typeof canon.projectGraphSnapshot === 'function')
34
+ snapshots[name] = canon.projectGraphSnapshot({ repoRoot });
35
+ }
36
+ catch { /* the cache builds this canon's graph itself */ }
37
+ }
38
+ fs.writeFileSync(handoff.out, JSON.stringify({ snapshots }));
39
+ }
13
40
  async function main() {
14
41
  const job = JSON.parse(fs.readFileSync(String(process.argv[2]), 'utf8'));
15
42
  const cwd = process.cwd();
@@ -17,6 +44,8 @@ async function main() {
17
44
  const results = await new ESLint({ cwd, fix: job.fix }).lintFiles(job.files);
18
45
  if (job.fix)
19
46
  await ESLint.outputFixes(results);
47
+ if (job.graph !== undefined)
48
+ await handOutGraph(job.graph, cwd);
20
49
  process.stdout.write(JSON.stringify(results));
21
50
  return results.some((result) => result.errorCount > 0 || result.fatalErrorCount > 0) ? 1 : 0;
22
51
  }
package/lint/run.d.mts CHANGED
@@ -1,11 +1,17 @@
1
1
  import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
2
- import { type CanonDeclarations, type LintSideStats } from '../cache/lint-family.mjs';
2
+ import { type CanonDeclarations, type GraphSnapshot, type LintSideStats } from '../cache/lint-family.mjs';
3
3
  import type { CheckSession } from '../cache/types.mjs';
4
4
  type Dynamic = any;
5
- /** What a linter run answers: its json results, or why it could not complete (both when the run was partial). */
5
+ /**
6
+ * The project-graph findings of one canon, as its `projectGraphSnapshot` answers them: by side-relative posix path.
7
+ * The ESLint child builds this graph for the canon's graph rules, so it hands the snapshot out instead of the cache building the same graph again.
8
+ */
9
+ export type GraphSnapshotParts = Readonly<Record<string, GraphSnapshot>>;
10
+ /** What a linter run answers: its json results, or why it could not complete (both when the run was partial), and the graph snapshots its child handed out, by canon name. */
6
11
  export interface LinterRun {
7
12
  results?: Dynamic[];
8
13
  error?: string;
14
+ graph?: GraphSnapshotParts;
9
15
  }
10
16
  /**
11
17
  * What a linter run is asked for. `list` is the files an ESLint run lints (cwd-relative, posix) when they are named one by one: they go to the child
@@ -20,6 +26,12 @@ export interface LinterSpec {
20
26
  bound?: string | null;
21
27
  list?: readonly string[] | undefined;
22
28
  files?: number | undefined;
29
+ graph?: readonly GraphCanon[] | undefined;
30
+ }
31
+ /** An installed canon whose graph snapshot the ESLint child hands out: its package name and the entry file the child imports. */
32
+ export interface GraphCanon {
33
+ name: string;
34
+ entry: string;
23
35
  }
24
36
  /** The seam that runs a linter through the app's own install from cwd. */
25
37
  export type LinterRunner = (spec: LinterSpec) => Promise<LinterRun>;
@@ -43,7 +55,7 @@ type RepoDeclaration = Dynamic;
43
55
  export declare const ESLINT_HEAP_ENV = "STARCI_ESLINT_HEAP_MB";
44
56
  export declare function eslintHeapArgs(env?: NodeJS.ProcessEnv): string[];
45
57
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
46
- export declare function runLinter({ cwd, pkg, bin, args, bound, list, files }: LinterSpec, execute?: HeavyNodeRun): Promise<LinterRun>;
58
+ export declare function runLinter({ cwd, pkg, bin, args, bound, list, files, graph }: LinterSpec, execute?: HeavyNodeRun): Promise<LinterRun>;
47
59
  /**
48
60
  * The lint scope a flat config declares: `ignored(file)` for its global ignores (a block holding only `ignores` and `name`), which prune
49
61
  * the walk, and `source(file)` for a file some block's `files` select while that block's own `ignores` do not (standard flat-config
@@ -66,7 +78,7 @@ export declare function installedLintConfig({ repoRoot, cwd, side }: {
66
78
  side: string;
67
79
  }): Promise<Dynamic[]>;
68
80
  /** Execute each side's installed ESLint config once, whole or over a named list; the cached and the cacheless run share scope, coverage and tool-failure judgment. */
69
- export declare function eslintRepository({ repoRoot, app, opts, changed, existing, run, scopes }: {
81
+ export declare function eslintRepository({ repoRoot, app, opts, changed, existing, run, scopes, handOutGraph }: {
70
82
  repoRoot: string;
71
83
  app?: RepoDeclaration;
72
84
  opts?: {
@@ -77,6 +89,7 @@ export declare function eslintRepository({ repoRoot, app, opts, changed, existin
77
89
  existing?: readonly string[];
78
90
  run?: LinterRunner | undefined;
79
91
  scopes?: Readonly<Record<string, readonly string[]>> | undefined;
92
+ handOutGraph?: boolean | undefined;
80
93
  }): Promise<{
81
94
  findings: LintFinding[];
82
95
  errors: string[];
@@ -87,6 +100,7 @@ export declare function eslintRepository({ repoRoot, app, opts, changed, existin
87
100
  skipped?: string;
88
101
  }>;
89
102
  };
103
+ graphs: Record<string, GraphSnapshotParts>;
90
104
  }>;
91
105
  /** What the cache reads of the canons a side lints with: the ESLint canon (which publishes the project-graph rules) and the grammar family of the fe side. */
92
106
  export declare function canonDeclarations({ repoRoot, cwd, side }: {
@@ -101,7 +115,7 @@ export declare function canonDeclarations({ repoRoot, cwd, side }: {
101
115
  export declare function gitBaseToken(repoRoot: string): string;
102
116
  /** What the cached linters answer: the ESLint answer in the shape of eslintRepository's, the stylelint results, and each side's counts. */
103
117
  export interface CachedLinters {
104
- eslint: Awaited<ReturnType<typeof eslintRepository>>;
118
+ eslint: Omit<Awaited<ReturnType<typeof eslintRepository>>, 'graphs'>;
105
119
  stylelint: {
106
120
  results: Dynamic[];
107
121
  errors: string[];
package/lint/run.mjs CHANGED
@@ -45,8 +45,23 @@ export function eslintHeapArgs(env = process.env) {
45
45
  }
46
46
  /** The ESLint list runner (eslint-list.cts, built beside this file). */
47
47
  const LIST_RUNNER = fileURLToPath(new URL('./eslint-list.cjs', import.meta.url));
48
+ /** The snapshots an ESLint child wrote, by canon name; undefined when it wrote none or a file that is not a snapshot list (the cache then builds the graph itself). */
49
+ function readGraphSnapshots(file) {
50
+ try {
51
+ const written = JSON.parse(fs.readFileSync(file, 'utf8'));
52
+ const snapshots = written?.snapshots;
53
+ if (snapshots === null || typeof snapshots !== 'object' || Array.isArray(snapshots))
54
+ return undefined;
55
+ const sound = Object.values(snapshots).every((snapshot) => snapshot !== null && typeof snapshot === 'object' && snapshot.byFile !== null && typeof snapshot.byFile === 'object' && !Array.isArray(snapshot.byFile)
56
+ && Object.values(snapshot.byFile).every((findings) => Array.isArray(findings)));
57
+ return sound ? snapshots : undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
48
63
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
49
- export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files }, execute) {
64
+ export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files, graph }, execute) {
50
65
  let entry;
51
66
  try {
52
67
  const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
@@ -57,18 +72,22 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files
57
72
  return { error: `${pkg} is not installed for ${cwd}` };
58
73
  }
59
74
  const heap = pkg === 'eslint' ? eslintHeapArgs() : [];
60
- // A named list is written to a file the runner reads: ESLint lints all of it in one process, and no command line holds a path.
61
- const listed = pkg === 'eslint' && list !== undefined ? makeTempDir('starci-eslint-') : null;
75
+ // A named list, or a run that hands its project graph out, goes through the list runner: the job is a file it reads (ESLint lints all of it in one
76
+ // process, no command line holds a path), the whole side is the list `.`, and the graph snapshots come back in a file beside the job.
77
+ const listed = pkg === 'eslint' && (list !== undefined || graph !== undefined) ? makeTempDir('starci-eslint-') : null;
62
78
  let childArgs = [entry, ...args];
63
- if (listed !== null && list !== undefined) {
79
+ const snapshotFile = listed === null || graph === undefined ? null : path.join(listed, 'graph.json');
80
+ if (listed !== null) {
64
81
  const job = path.join(listed, 'job.json');
65
- fs.writeFileSync(job, JSON.stringify({ files: list, fix: args.includes('--fix') }));
82
+ fs.writeFileSync(job, JSON.stringify({ files: list ?? ['.'], fix: args.includes('--fix'), ...(snapshotFile === null || graph === undefined ? {} : { graph: { out: snapshotFile, canons: graph } }) }));
66
83
  childArgs = [LIST_RUNNER, job];
67
84
  }
68
85
  const count = list?.length ?? files;
69
86
  let run;
87
+ let handed;
70
88
  try {
71
89
  run = completedNode(await runHeavyNode(execute, { kind: 'eslint', label: `app check ${pkg} ${path.basename(cwd)}`, args: [...heap, ...(bound ? linterBoundArgs() : []), ...childArgs], cwd, maxBuffer: 64 * 1024 * 1024, ...(count === undefined || pkg !== 'eslint' ? {} : { files: count }), ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) }), pkg);
90
+ handed = snapshotFile === null ? undefined : readGraphSnapshots(snapshotFile);
72
91
  }
73
92
  finally {
74
93
  if (listed !== null)
@@ -87,7 +106,7 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null, list, files
87
106
  return { error: `${pkg} produced a report that is not a result list` };
88
107
  const completed = !run.error && !run.signal && [0, 1].includes(run.status)
89
108
  && (run.status === 0 || results.some((result) => result?.messages?.length || result?.warnings?.length));
90
- return { results, ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
109
+ return { results, ...(handed === undefined || !completed ? {} : { graph: handed }), ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
91
110
  }
92
111
  const eslintFindings = (results, appRoot) => results.flatMap((result) => (result.messages ?? []).map((message) => ({
93
112
  engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: message.severity === 1 ? 'warning' : 'error',
@@ -268,8 +287,18 @@ const spawnFailure = (error) => {
268
287
  throw error;
269
288
  return { error: `could not start eslint: ${String(error?.message ?? error)}` };
270
289
  };
290
+ /** The installed canon of `side`, which publishes the project-graph rules, as the child imports it; undefined when it cannot be resolved (the cache then builds the graph itself). */
291
+ function graphCanonsOf(repoRoot, cwd, side) {
292
+ const name = `@starci/eslint-canon-${side}`;
293
+ try {
294
+ return [{ name, entry: ownCanonEntry({ repoRoot, cwd }, name) }];
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ }
271
300
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
272
- async function launchEslint({ app, repoRoot, opts, changed, existing, run = runLinter, scopes = {} }) {
301
+ async function launchEslint({ app, repoRoot, opts, changed, existing, run = runLinter, scopes = {}, handOutGraph = false }) {
273
302
  const launches = [];
274
303
  for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
275
304
  const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
@@ -289,20 +318,22 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, run = runL
289
318
  continue;
290
319
  }
291
320
  const head = ['--format', 'json', ...(opts.fix ? ['--fix'] : [])];
321
+ // the child that builds the canon's project graph for its rules hands the snapshot out when the caller keeps the graph findings (the cache)
322
+ const graph = handOutGraph ? graphCanonsOf(repoRoot, cwd, side) : undefined;
292
323
  // The files of a diff go to ONE child through a file (a diff of hundreds of long paths outgrows the Windows command line, and each extra
293
324
  // child would build the whole TypeScript program again); the whole side is a single directory argument.
294
- const launch = Promise.resolve().then(() => run(changed ? { cwd, pkg: 'eslint', bin: 'eslint', args: head, list: sources, bound: repoRoot } : { cwd, pkg: 'eslint', bin: 'eslint', args: [...head, '.'], files: expected.length, bound: repoRoot }));
325
+ const launch = Promise.resolve().then(() => run(changed ? { cwd, pkg: 'eslint', bin: 'eslint', args: head, list: sources, bound: repoRoot, ...(graph === undefined ? {} : { graph }) } : { cwd, pkg: 'eslint', bin: 'eslint', args: [...head, '.'], files: expected.length, bound: repoRoot, ...(graph === undefined ? {} : { graph }) }));
295
326
  launches.push({ side, cwd, expected, run: launch.catch(spawnFailure) });
296
327
  }
297
328
  return launches;
298
329
  }
299
330
  /** Execute each side's installed ESLint config once, whole or over a named list; the cached and the cacheless run share scope, coverage and tool-failure judgment. */
300
- export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], run, scopes }) {
301
- const findings = [], errors = [], raw = [];
331
+ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loadSlotManifest(), repoRoot), opts = {}, changed = null, existing = [], run, scopes, handOutGraph }) {
332
+ const findings = [], errors = [], raw = [], graphs = {};
302
333
  const engine = { sides: {} };
303
334
  if (!app.sides)
304
335
  throw new Error('ESLint runs at the app root, the folder of hfs.json');
305
- const launches = await launchEslint({ app, repoRoot, opts, changed, existing, run, ...(scopes === undefined ? {} : { scopes }) });
336
+ const launches = await launchEslint({ app, repoRoot, opts, changed, existing, run, ...(scopes === undefined ? {} : { scopes }), ...(handOutGraph === true ? { handOutGraph } : {}) });
306
337
  const settled = await Promise.allSettled(launches.map(launch => launch.run ?? null));
307
338
  const rejected = settled.filter((run) => run.status === 'rejected');
308
339
  const failed = rejected.find(run => isHostHeld(run.reason)) ?? rejected[0];
@@ -335,8 +366,10 @@ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loa
335
366
  findings.push(...eslintFindings(linted.results, repoRoot));
336
367
  }
337
368
  engine.sides[side] = { files: linted.results?.length ?? 0 };
369
+ if (linted.graph !== undefined)
370
+ graphs[side] = linted.graph;
338
371
  });
339
- return { findings, errors, raw, engine };
372
+ return { findings, errors, raw, engine, graphs };
340
373
  }
341
374
  // ------------------------------------------------------------------------------------------------ the cached lint
342
375
  /** A `crossFileInputs` export that has the shape the cache reads, or null (an installed canon without it, or with a broken one, is linted whole). */
@@ -441,8 +474,8 @@ export async function cachedLinters({ repoRoot, app, side: only, run, session })
441
474
  canons, styleCanon: styled ? await styleDeclaration({ repoRoot, cwd }) : null, typescript: () => loadAppTypeScript(cwd), extraKey: '', gitToken, full, write: true,
442
475
  eslint: async (files) => {
443
476
  const listed = files === null ? null : files.map((file) => `${side}/${file}`);
444
- const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], run, scopes: { [side]: scope } });
445
- return { results: sub.raw, errors: sub.errors };
477
+ const sub = await eslintRepository({ repoRoot, app, opts: { side }, changed: listed, existing: listed ?? [], run, scopes: { [side]: scope }, handOutGraph: true });
478
+ return { results: sub.raw, errors: sub.errors, ...(sub.graphs[side] === undefined ? {} : { graph: sub.graphs[side] }) };
446
479
  },
447
480
  stylelint: styled ? async (files) => {
448
481
  const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [STYLE_GLOB]), '--formatter', 'json'] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.0.8",
3
+ "version": "5.1.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.21.12
34
+ version: 0.21.13
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.19
41
+ version: 3.12.0
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.9.12
48
+ version: 9.10.0
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.21.12
55
+ version: 0.21.13
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.21.12
62
+ version: 0.21.13
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -92,7 +92,7 @@ pins:
92
92
  source: packages/jest-preset/package.json
93
93
  why: 'the Jest preset of a back end: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: it pairs exactly with @starci/test-world, and any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. The unit run writes the lcov Sonar and Codecov import (services only); the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; it ships the unit kit (mockEntityManager, fakeTransaction, fakeQueryBuilder, queueQueryBuilders, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
94
94
  '@starci/test-world':
95
- version: 1.3.3
95
+ version: 1.3.4
96
96
  group: starci
97
97
  install: registry
98
98
  side: be
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 5.0.8
109
+ version: 5.1.0
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -924,7 +924,7 @@ rules:
924
924
  code: "BE_TEST_TOPOLOGY"
925
925
  editions: [lite, full]
926
926
  docs: [docs/quality-bar.md#r47-test-topology]
927
- law: "One jest config, projects `unit`, `integration`, `e2e` and `contract`, live by folder, `diagnostics: false`; unit specs are `<name>.<role>.spec.ts` beside the file they test: required for the roles of `ruleParams.be.unitRoles` (`<name>.service.spec.ts` beside a `<name>.service.ts`, `<name>.cli.spec.ts` beside a cli command `<name>.cli.ts` of `src/features/cli`), optional for any other logic role (`ruleParams.be.logicRoles`) inside a slot whose `coverage` is required and beside a logic file such a slot declares (`logic`: `passport.tokens.spec.ts` beside `passport.tokens.ts`), except that the commit adding a measured logic file stages its `<name>.<role>.spec.ts` beside it (owner 2026-10-20, judged by `starci app check --staged` in the pre-commit hook), and nowhere else but beside a webhook or realtime door that has no service of its own (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<x>.subscription.spec.ts`) (every unit-tested subject has exactly one; any other `*.spec.ts` outside `be/src/tests/{world,integration,e2e,contract}` is a finding, including specs in apps), and the unit project collects coverage from the logic roles of the measured roots and the logic files their slots declare only (the scope derived from the slot manifest, R204) with a per-file threshold of 100 on lines, branches, functions and statements (`test` runs `--coverage`; `importHelpers` and `tslib`); `be/src/tests/world/` is the ONLY test infrastructure: `global-setup.ts` starts the app's own stack (`.starcistacks/<env>`, named by `stack` in `test-world.config.ts`, the declaration of the test-world library in its one named form `export const { useTestWorld, useSandbox } = defineTestWorld({...})` (a default export is not read): every service it declares runs REAL, behind toxiproxy, images and versions read from the stack at run time, never spelled in test source) or attaches to a warm one that answers, and runs the migrations once over the cli app's connections (the runner of the cli migrate command); `global-teardown.ts` stops only what its setup started; `use-test-world.ts` exports `useTestWorld({ apps } | { modules })` (`world.apps.<name>.api`, `world.db.<connection>`, `world.infra.<service>` with `latency(ms)`, `cut()` and `restore()` on the real service, `world.fake.<provider>`, `world.waitFor`) and `fakes/<provider>/` holds the network-edge fakes (`failNext`, `replayWebhook`, `delay`, payload fixtures) of external SaaS the team does not operate ONLY: a fake of a service the stack declares is refused, except stateless compute that needs special hardware or an external model (GPU inference, a self-hosted embedding model), whose `stacks` entry in `test-world.config.ts` declares `{ fakedBy: <fake>, reason }` with a non-empty `reason` (a stateful service, or one with a persistent volume, is never accepted); it alone owns containers, DataSource, migrations and `process.env`; nothing under `be/src/tests` overrides a provider or DI token; `be/src/tests/integration/<capability>/*.integration-spec.ts` (`{ modules }`, with the real peer apps its client calls beside them as `apps`; real infrastructure, no HTTP door of ours), `be/src/tests/e2e/<area>/*.e2e-spec.ts` (`{ apps }`) and `be/src/tests/contract/<provider>/*.contract-spec.ts` (provider sandboxes, skipped without sandbox config, run only by `test:contract`, never by `test` or `test:e2e`) agree folder with suffix; an integration or e2e spec passes `useTestWorld` one object literal with plain keys (no variable, spread or computed key) and calls it; the contract layer has no skip either: a contract spec writes `sandbox.describe(...)` from the test-world library's `useSandbox(...)`, and when the sandbox config is absent that describe registers one failing test naming the missing keys (the body never runs), and every `fakes/<provider>/` that serves payload fixtures (`payloads/*.json`) has a `contract/<provider>/*.contract-spec.ts` that references those fixtures and asserts `shapeOf(real)` equals `shapeOf(fixture)` (the helper `shapeOf` is named by `ruleParams.be.contractShape` of the slot manifest; fakes the test-world library provides are guarded by the library); no `.test.ts`, `int-spec` or `harness-spec`, no `live/` and no `be/src/tests/e2e/world`."
927
+ law: "One jest config, projects `unit`, `integration`, `e2e` and `contract`, live by folder, `diagnostics: false`; unit specs are `<name>.<role>.spec.ts` beside the file they test: required for the roles of `ruleParams.be.unitRoles` (`<name>.service.spec.ts` beside a `<name>.service.ts`, `<name>.cli.spec.ts` beside a cli command `<name>.cli.ts` of `src/features/cli`), optional for any other logic role (`ruleParams.be.logicRoles`) inside a slot whose `coverage` is required and beside a logic file such a slot declares (`logic`: `passport.tokens.spec.ts` beside `passport.tokens.ts`), except that the commit adding a measured logic file stages its `<name>.<role>.spec.ts` beside it (owner 2026-10-20, judged by `starci app check --staged` in the pre-commit hook), and nowhere else but beside a webhook or realtime door that has no service of its own (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<x>.subscription.spec.ts`) (every unit-tested subject has exactly one; any other `*.spec.ts` outside `be/src/tests/{world,integration,e2e,contract}` is a finding, including specs in apps), and the unit project collects coverage from the logic roles of the measured roots and the logic files their slots declare only (the scope derived from the slot manifest, R204) with a per-file threshold of 100 on lines, branches, functions and statements (`test` runs `--coverage`; `importHelpers` and `tslib`); `be/src/tests/world/` is the ONLY test infrastructure: `global-setup.ts` starts the app's own stack (`.starcistacks/<env>`, named by `stack` in `test-world.config.ts`, the declaration of the test-world library in a named form: `export const { useTestWorld, useSandbox } = defineTestWorld({...})`, or `const world: TestWorldApi = defineTestWorld({...})` with `useTestWorld` and `useSandbox` exported typed from it (a default export is not read): every service it declares runs REAL, behind toxiproxy, images and versions read from the stack at run time, never spelled in test source) or attaches to a warm one that answers, and runs the migrations once over the cli app's connections (the runner of the cli migrate command); `global-teardown.ts` stops only what its setup started; `use-test-world.ts` exports `useTestWorld({ apps } | { modules })` (`world.apps.<name>.api`, `world.db.<connection>`, `world.infra.<service>` with `latency(ms)`, `cut()` and `restore()` on the real service, `world.fake.<provider>`, `world.waitFor`) and `fakes/<provider>/` holds the network-edge fakes (`failNext`, `replayWebhook`, `delay`, payload fixtures) of external SaaS the team does not operate ONLY: a fake of a service the stack declares is refused, except stateless compute that needs special hardware or an external model (GPU inference, a self-hosted embedding model), whose `stacks` entry in `test-world.config.ts` declares `{ fakedBy: <fake>, reason }` with a non-empty `reason` (a stateful service, or one with a persistent volume, is never accepted); it alone owns containers, DataSource, migrations and `process.env`; nothing under `be/src/tests` overrides a provider or DI token; `be/src/tests/integration/<capability>/*.integration-spec.ts` (`{ modules }`, with the real peer apps its client calls beside them as `apps`; real infrastructure, no HTTP door of ours), `be/src/tests/e2e/<area>/*.e2e-spec.ts` (`{ apps }`) and `be/src/tests/contract/<provider>/*.contract-spec.ts` (provider sandboxes, skipped without sandbox config, run only by `test:contract`, never by `test` or `test:e2e`) agree folder with suffix; an integration or e2e spec passes `useTestWorld` one object literal with plain keys (no variable, spread or computed key) and calls it; the contract layer has no skip either: a contract spec writes `sandbox.describe(...)` from the test-world library's `useSandbox(...)`, and when the sandbox config is absent that describe registers one failing test naming the missing keys (the body never runs), and every `fakes/<provider>/` that serves payload fixtures (`payloads/*.json`) has a `contract/<provider>/*.contract-spec.ts` that references those fixtures and asserts `shapeOf(real)` equals `shapeOf(fixture)` (the helper `shapeOf` is named by `ruleParams.be.contractShape` of the slot manifest; fakes the test-world library provides are guarded by the library); no `.test.ts`, `int-spec` or `harness-spec`, no `live/` and no `be/src/tests/e2e/world`."
928
928
  kinds: [check, codemod, lint]
929
929
  gates: [land, ci]
930
930
  failureCodes: ["BE_TEST_TOPOLOGY", "BE_CONTRACT_UNGUARDED", "BE_UNIT_SPEC_NOT_STAGED"]
@@ -6525,3 +6525,54 @@ rules:
6525
6525
  source: owner
6526
6526
  quote: "The skeleton is the ready structure with its data missing; it is drawn from isLoading alone and from the reads the region shows."
6527
6527
  ruling: "Several reads feed one frame: loading is any read loading with no data; a key change reloads only the region keyed by it; a refresh keeps the content."
6528
+ - id: R368
6529
+ code: "HFS_EXPLICIT_EXPORT_SURFACE"
6530
+ law: "An exported declaration states its type where it is exported: the return type of an exported function, the type of an exported variable and the type of its parameters are written, not inferred from a body or from other files, because the isolated declaration emit must be able to write the file's surface alone. A component that returns only JSX is explicit. Each export that cannot be written is reported at its place with what to annotate, in a front-end and a back-end file alike."
6531
+ kinds: [lint]
6532
+ gates: [pre-commit, settle, land, ci]
6533
+ failureCodes: ["HFS_EXPLICIT_EXPORT_SURFACE"]
6534
+ enforcers:
6535
+ - {kind: eslint-fe, id: explicit-export-surface}
6536
+ - {kind: eslint-be, id: explicit-export-surface}
6537
+ editions: [lite, full]
6538
+ docs: [contexts/code/shared/typing.md]
6539
+ templateNa: "A template exports nothing the lint result cache keys on."
6540
+ proves:
6541
+ - {path: packages/eslint-canon-fe/src/explicit-surface.spec.ts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6542
+ - {path: packages/eslint-canon-be/src/explicit-surface.spec.ts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6543
+ - {path: packages/hfs/src/explicit-surface.spec.mts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6544
+ exemplifiedBy:
6545
+ - {path: packages/eslint-canon-fe/src/explicit-surface.spec.ts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6546
+ witness:
6547
+ - {path: packages/eslint-canon-fe/src/explicit-surface.spec.ts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6548
+ - {path: packages/eslint-canon-be/src/explicit-surface.spec.ts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6549
+ - {path: packages/hfs/src/explicit-surface.spec.mts, editions: [lite, full], codes: [HFS_EXPLICIT_EXPORT_SURFACE]}
6550
+ cases:
6551
+ - id: case-1
6552
+ date: "2026-11-03"
6553
+ source: owner
6554
+ quote: "enforce it by lint"
6555
+ ruling: "A file whose exported surface is inferred is open in the lint result cache: one body edit lints every importer again. The exported surface is written out, and the rule reports exactly what the cache counts as open."
6556
+ - id: R369
6557
+ code: "FE_DIRECTIVE_FIRST"
6558
+ law: "A `use client` or `use server` directive is the first statement of its module, above every import: below another statement it is an expression that does nothing (Prettier writes it as a parenthesised string) and next build refuses the file."
6559
+ kinds: [lint]
6560
+ gates: [pre-commit, settle, land, ci]
6561
+ failureCodes: ["FE_DIRECTIVE_FIRST"]
6562
+ enforcers:
6563
+ - {kind: eslint-fe, id: directive-first}
6564
+ editions: [lite, full]
6565
+ docs: [contexts/code/fe/boundaries.md]
6566
+ templateNa: "A template places its directives first."
6567
+ proves:
6568
+ - {path: packages/eslint-canon-fe/src/directive-first.spec.ts, editions: [lite, full], codes: [FE_DIRECTIVE_FIRST]}
6569
+ exemplifiedBy:
6570
+ - {path: packages/eslint-canon-fe/src/directive-first.spec.ts, editions: [lite, full], codes: [FE_DIRECTIVE_FIRST]}
6571
+ witness:
6572
+ - {path: packages/eslint-canon-fe/src/directive-first.spec.ts, editions: [lite, full], codes: [FE_DIRECTIVE_FIRST]}
6573
+ cases:
6574
+ - id: case-1
6575
+ date: "2026-11-03"
6576
+ source: owner
6577
+ quote: "no lint caught it"
6578
+ ruling: "A directive below an import broke Nivo's CI build on 2026-11-02; the directive is the first statement of its module and a lint reports it elsewhere."
@@ -18,9 +18,10 @@ interface WorldViolation {
18
18
  * its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
19
19
  * (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
20
20
  * matched by the slot's own entries through `allowsFile`.
21
- * 2. The declaration. `test-world.config.ts` declares the world in ONE form, the named export R89 allows:
22
- * `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })` (an exported `const` initialized by a
23
- * `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
21
+ * 2. The declaration. `test-world.config.ts` declares the world in a named form R89 allows: the destructured export
22
+ * `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })`, or the typed form
23
+ * `const world: TestWorldApi = defineTestWorld({ ... })` whose `useTestWorld` and `useSandbox` are exported consts typed from
24
+ * `TestWorldApi` (a `const` initialized by a `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
24
25
  * read) is refused: the world it declares could not be judged.
25
26
  * 3. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world; a fake
26
27
  * (a `fakes/<provider>/` folder, or a `fakes` entry of the declaration such as `smtp: smtpFake()`) is a network-edge fake
@@ -15,9 +15,10 @@ import { byCodeUnit } from '../../lib/list.mjs';
15
15
  * its own slot, be.tests.world.kit) plus files at its root whose role suffix is in ruleParams.be.suffixes
16
16
  * (`stripe.client.ts`, `checkout.contracts.ts`, `test-world.config.ts`, ...). Every tracked file below the world root is
17
17
  * matched by the slot's own entries through `allowsFile`.
18
- * 2. The declaration. `test-world.config.ts` declares the world in ONE form, the named export R89 allows:
19
- * `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })` (an exported `const` initialized by a
20
- * `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
18
+ * 2. The declaration. `test-world.config.ts` declares the world in a named form R89 allows: the destructured export
19
+ * `export const { useTestWorld, useSandbox } = defineTestWorld({ ... })`, or the typed form
20
+ * `const world: TestWorldApi = defineTestWorld({ ... })` whose `useTestWorld` and `useSandbox` are exported consts typed from
21
+ * `TestWorldApi` (a `const` initialized by a `defineTestWorld(<object literal>)` call). A config without it (a default export, or a declaration the machine cannot
21
22
  * read) is refused: the world it declares could not be judged.
22
23
  * 3. Fakes against the stack (owner refinement 2026-09-30). Every service the stack declares runs real in the world; a fake
23
24
  * (a `fakes/<provider>/` folder, or a `fakes` entry of the declaration such as `smtp: smtpFake()`) is a network-edge fake
@@ -38,6 +39,7 @@ const FAKES_DIRECTORY = 'fakes/';
38
39
  const CONFIG_FILE = 'test-world.config.ts';
39
40
  const DEFINE = 'defineTestWorld';
40
41
  const NAMED_FORM = `export const { useTestWorld, useSandbox } = ${DEFINE}({ ... })`;
42
+ const TYPED_FORM = `const world: TestWorldApi = ${DEFINE}({ ... })` + ' with export const useTestWorld / useSandbox typed from it';
41
43
  const defaultEnvironment = DEFAULT_ENVIRONMENT;
42
44
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
43
45
  const unwrap = (ts, node) => unwrapEach(ts, node, [ts.isParenthesizedExpression, ts.isAsExpression, ts.isSatisfiesExpression]);
@@ -52,21 +54,38 @@ function defineLiteral(ts, expression) {
52
54
  const literal = first ? unwrap(ts, first) : null;
53
55
  return literal && ts.isObjectLiteralExpression(literal) ? literal : null;
54
56
  }
55
- /** The declaration of the world: the object literal of an exported `const` initialized by `defineTestWorld({ ... })`, or null. */
57
+ /**
58
+ * The declaration of the world: the object literal of a top-level `const` initialized by `defineTestWorld({ ... })`, or null. The config
59
+ * exports `useTestWorld` and `useSandbox` either by destructuring that call (the original form) or, typed, from the world the call
60
+ * built: `const world: TestWorldApi = defineTestWorld({ ... })` with `export const useTestWorld: TestWorldApi["useTestWorld"] = world.useTestWorld`.
61
+ * Both are read; a world no exported name reaches is refused by the caller.
62
+ */
56
63
  function configLiteral(ts, sourceFile) {
57
64
  for (const statement of sourceFile.statements) {
58
- if (!ts.isVariableStatement(statement) || !isExported(ts, statement))
65
+ if (!ts.isVariableStatement(statement))
59
66
  continue;
60
67
  if (!hasAnyFlag(statement.declarationList.flags, ts.NodeFlags.Const))
61
68
  continue;
62
69
  for (const declaration of statement.declarationList.declarations) {
63
70
  const literal = declaration.initializer ? defineLiteral(ts, declaration.initializer) : null;
64
- if (literal)
71
+ if (literal && (isExported(ts, statement) || exportsWorld(ts, sourceFile)))
65
72
  return literal;
66
73
  }
67
74
  }
68
75
  return null;
69
76
  }
77
+ /** Whether the file exports both `useTestWorld` and `useSandbox` as consts (the typed form re-exports them from the built world). */
78
+ function exportsWorld(ts, sourceFile) {
79
+ const exported = new Set();
80
+ for (const statement of sourceFile.statements) {
81
+ if (!ts.isVariableStatement(statement) || !isExported(ts, statement))
82
+ continue;
83
+ for (const declaration of statement.declarationList.declarations)
84
+ if (ts.isIdentifier(declaration.name))
85
+ exported.add(declaration.name.text);
86
+ }
87
+ return exported.has('useTestWorld') && exported.has('useSandbox');
88
+ }
70
89
  /** `stack` as the environment it names: the last segment of `".starcistacks/<env>"`; null when absent or not a literal. */
71
90
  function environmentOf(ts, literal) {
72
91
  const node = literal ? propertyOf(ts, literal, 'stack') : null;
@@ -152,7 +171,7 @@ function worldDeclaration(graph, ts, worldRoot, violations) {
152
171
  if (configFile && !literal) {
153
172
  violations.push({
154
173
  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.`,
174
+ message: `${configPath} declares no world the machine can read. Declare it in the named form \`${NAMED_FORM}\` or the typed form \`${TYPED_FORM}\` (a 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
175
  });
157
176
  }
158
177
  return { configPath, configFile, literal };