@starci/hfs 5.0.7 → 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 (204) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +7 -8
  3. package/cache/fake-session.mjs +1 -1
  4. package/cache/hfs-family.mjs +22 -9
  5. package/cache/import-graph.d.mts +32 -4
  6. package/cache/import-graph.mjs +88 -11
  7. package/cache/lint-family.d.mts +10 -3
  8. package/cache/lint-family.mjs +65 -37
  9. package/cache/records.d.mts +7 -0
  10. package/cache/records.mjs +17 -1
  11. package/cache/run.d.mts +1 -1
  12. package/cache/session.mjs +6 -2
  13. package/cache/side-key.d.mts +2 -0
  14. package/cache/side-key.mjs +5 -1
  15. package/cache/structure-memo.d.mts +34 -0
  16. package/cache/structure-memo.mjs +150 -0
  17. package/cache/types.d.mts +6 -2
  18. package/cache/wiring-family.mjs +1 -1
  19. package/codemod/directive-first.d.mts +48 -0
  20. package/codemod/directive-first.mjs +136 -0
  21. package/codemod/explicit-surface-edit.d.mts +14 -0
  22. package/codemod/explicit-surface-edit.mjs +57 -0
  23. package/codemod/explicit-surface-names.d.mts +30 -0
  24. package/codemod/explicit-surface-names.mjs +135 -0
  25. package/codemod/explicit-surface-render.d.mts +29 -0
  26. package/codemod/explicit-surface-render.mjs +197 -0
  27. package/codemod/explicit-surface.d.mts +50 -0
  28. package/codemod/explicit-surface.mjs +374 -0
  29. package/codemod/main.d.mts +7 -5
  30. package/codemod/main.mjs +37 -16
  31. package/codemod/small-renames.mjs +22 -2
  32. package/codemod/source-tree.d.mts +8 -0
  33. package/codemod/source-tree.mjs +23 -0
  34. package/lint/eslint-list.cjs +55 -0
  35. package/lint/run.d.mts +35 -61
  36. package/lint/run.mjs +91 -288
  37. package/package.json +1 -1
  38. package/release/lint-report.d.mts +1 -1
  39. package/release/lint-report.mjs +3 -3
  40. package/report/sonar.d.mts +1 -1
  41. package/report/sonar.mjs +3 -3
  42. package/report/stylelint-findings.d.mts +23 -0
  43. package/report/stylelint-findings.mjs +9 -0
  44. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  45. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  46. package/runtime/knowledge/hfs/rules.yaml +56 -5
  47. package/runtime/knowledge/hfs/slots.yaml +1 -1
  48. package/runtime/knowledge/sonar-gate.yaml +2 -2
  49. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  50. package/runtime/scripts/api/node/run-node.d.mts +5 -0
  51. package/runtime/scripts/hfs/architecture/test-world-files.d.mts +4 -3
  52. package/runtime/scripts/hfs/architecture/test-world-files.mjs +26 -7
  53. package/runtime/scripts/hfs/check.d.mts +16 -1
  54. package/runtime/scripts/hfs/check.mjs +18 -7
  55. package/runtime/scripts/hfs/explicit-surface-rule.d.mts +58 -0
  56. package/runtime/scripts/hfs/explicit-surface-rule.mjs +59 -0
  57. package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
  58. package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
  59. package/runtime/scripts/hfs/rule-memo.mjs +24 -0
  60. package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
  61. package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
  62. package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
  63. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
  64. package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
  65. package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
  66. package/runtime/scripts/hfs/rules/read.d.mts +5 -0
  67. package/runtime/scripts/hfs/rules/read.mjs +13 -0
  68. package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
  69. package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
  70. package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
  71. package/runtime/scripts/lib/declaration-surface.d.mts +35 -0
  72. package/runtime/scripts/lib/declaration-surface.mjs +112 -0
  73. package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
  74. package/scaffold/app.mjs +3 -3
  75. package/src/check-cache-structure-support.d.mts +52 -0
  76. package/src/main.d.mts +3 -2
  77. package/src/main.mjs +123 -109
  78. package/sync/ci-commands.d.mts +2 -1
  79. package/sync/ci-commands.mjs +19 -20
  80. package/sync/index.mjs +2 -2
  81. package/templates/app/package-scripts/core.json +2 -2
  82. package/templates/app/task-graph/turbo.json +0 -3
  83. package/templates/be/inbox/delivery-recorded.event.ts.tpl +2 -2
  84. package/templates/be/inbox/entities.index.ts.tpl +1 -1
  85. package/templates/be/inbox/module-definition.ts.tpl +8 -2
  86. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +10 -2
  87. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +8 -2
  88. package/templates/be/patterns/api/feature.module-definition.ts.tpl +9 -3
  89. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +9 -3
  90. package/templates/be/patterns/cli/group.module-definition.ts.tpl +9 -3
  91. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +8 -3
  92. package/templates/be/patterns/event-bus/platform/persistence/connection.ts.tpl +3 -2
  93. package/templates/be/patterns/event-bus/platform/persistence/event-bus.sql.ts.tpl +4 -3
  94. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +8 -3
  95. package/templates/be/patterns/idempotency/platform/inbox/persistence/connection.ts.tpl +3 -2
  96. package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.sql.ts.tpl +7 -6
  97. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +9 -3
  98. package/templates/be/patterns/jobs/job.processor.ts.tpl +1 -1
  99. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +9 -3
  100. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +8 -3
  101. package/templates/be/patterns/jobs/platform/persistence.connection.ts.tpl +3 -2
  102. package/templates/be/patterns/jobs/platform/persistence.jobs.sql.ts.tpl +5 -4
  103. package/templates/be/patterns/projections/persistence.connection.ts.tpl +3 -2
  104. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +8 -3
  105. package/templates/be/patterns/queues/platform/persistence.connection.ts.tpl +3 -2
  106. package/templates/be/patterns/queues/platform/persistence.queue.sql.ts.tpl +4 -3
  107. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +8 -3
  108. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +9 -3
  109. package/templates/be/patterns/reactors/consumer.ts.tpl +1 -1
  110. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +9 -3
  111. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +9 -3
  112. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +9 -3
  113. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +9 -3
  114. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +9 -3
  115. package/templates/be/patterns/saga/saga.done.consumer.ts.tpl +1 -1
  116. package/templates/be/patterns/saga/saga.failed.consumer.ts.tpl +1 -1
  117. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +9 -3
  118. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +9 -3
  119. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +9 -3
  120. package/templates/be/platform/graphql/graphql.module-definition.ts +8 -3
  121. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +9 -3
  122. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +9 -3
  123. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +9 -3
  124. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +9 -3
  125. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +9 -3
  126. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +8 -3
  127. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +8 -3
  128. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +8 -3
  129. package/templates/be/skeleton/src/modules/domain/note/persistence/connection.ts +3 -2
  130. package/templates/be/skeleton/src/modules/domain/note/persistence/note.sql.ts +2 -1
  131. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +8 -3
  132. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +8 -3
  133. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +8 -3
  134. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  135. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +10 -4
  136. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +8 -4
  137. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +10 -4
  138. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +3 -2
  139. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +3 -2
  140. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +10 -4
  141. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +8 -3
  142. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +8 -3
  143. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +8 -3
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +1 -1
  145. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +5 -1
  146. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +5 -1
  147. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +1 -1
  148. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +1 -1
  149. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +7 -1
  150. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +7 -1
  151. package/templates/be/table/api/feature.module-definition.ts.tpl +9 -3
  152. package/templates/be/table/api/graphql.module-definition.ts.tpl +9 -3
  153. package/templates/be/table/entities.index.ts.tpl +1 -1
  154. package/templates/be/table/module-definition.ts.tpl +8 -3
  155. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +1 -0
  156. package/templates/be/test-world/world/test-world.config.ts.tpl +8 -1
  157. package/templates/fe/common/app-layout.tsx.partial +1 -1
  158. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +2 -1
  159. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +11 -3
  160. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +8 -2
  161. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +2 -1
  162. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +1 -1
  163. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +4 -1
  164. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +3 -2
  165. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +1 -0
  166. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +12 -4
  167. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +8 -3
  168. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +4 -3
  169. package/templates/fe/skeleton/apps/app/src/features/layouts/AppLayout/index.tsx +1 -1
  170. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +2 -1
  171. package/templates/fe/skeleton/apps/app/src/features/pages/AppLoadingPage/index.tsx +2 -1
  172. package/templates/fe/skeleton/apps/app/src/features/pages/AppNotFoundPage/index.tsx +2 -1
  173. package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +2 -1
  174. package/templates/fe/skeleton/apps/app/src/modules/i18n/request.ts +4 -1
  175. package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +4 -3
  176. package/templates/fe/skeleton/apps/landing/src/features/layouts/LandingLayout/index.tsx +2 -2
  177. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingLoadingPage/index.tsx +2 -1
  178. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingNotFoundPage/index.tsx +2 -1
  179. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/index.tsx +2 -1
  180. package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +2 -1
  181. package/templates/fe/skeleton/apps/landing/src/modules/i18n/request.ts +4 -1
  182. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +19 -1
  183. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +1 -1
  184. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +5 -1
  185. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +4 -3
  186. package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +2 -2
  187. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -1
  188. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppLoadingPage/index.tsx +2 -1
  189. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppNotFoundPage/index.tsx +2 -1
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +3 -1
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +5 -1
  192. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +2 -1
  193. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +2 -1
  194. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  195. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +1 -1
  196. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +1 -1
  197. package/templates/fe/transport/graphql/clients/links/error.ts +1 -1
  198. package/templates/fe/transport/graphql/clients/links/http.ts +1 -1
  199. package/templates/fe/transport/graphql/clients/links/locale.ts +1 -1
  200. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +1 -1
  201. package/templates/fe/transport/graphql/clients/links/retry.ts +1 -1
  202. package/templates/fe/transport/graphql/clients/links/timeout.ts +1 -1
  203. package/templates/fe/units/page/index.tsx.tpl +3 -2
  204. package/templates/fe/units/slot/SlotView.tsx.tpl +1 -1
@@ -0,0 +1,30 @@
1
+ import type { CheckFinding } from './check.mjs';
2
+ /** A rule whose findings for one file depend on that file alone (and the files `deps` names), so the cache reuses them per file. */
3
+ export interface FileRule {
4
+ /** Names the rule in its record and in the spec that proves its declaration. */
5
+ readonly id: string;
6
+ /** The findings over `files` (scope-relative tracked paths); every finding's `path` is one of `files`. */
7
+ run(files: string[]): CheckFinding[];
8
+ /** Other scope-relative files whose content the findings for `file` read. */
9
+ deps?(file: string): string[];
10
+ }
11
+ /** What the cache gives the rule runner. */
12
+ export interface RuleMemo {
13
+ /** The findings of `rules` over the tracked `files` of the scope at `scopeRoot` (absolute), reusing what the cache holds per file. */
14
+ files(scopeRoot: string, files: readonly string[], rules: readonly FileRule[]): CheckFinding[];
15
+ /** The findings of the cross-file rule `id` of the scope at `scopeRoot`, reused while every file it read and the tracked path list are unchanged. */
16
+ keyed(scopeRoot: string, id: string, run: () => CheckFinding[]): CheckFinding[];
17
+ /**
18
+ * The value `compute` yields for `file` (scope-relative) of the scope at `scopeRoot`, reused while the file's bytes and `extra` (every other input the value
19
+ * depends on, as text) are unchanged. `compute` reads only that file; the value is plain JSON; `empty` is the value a file with nothing to say yields.
20
+ */
21
+ unit<T>(scopeRoot: string, id: string, file: string, extra: string, empty: T, compute: () => T): T;
22
+ }
23
+ /** Runs `run` with `memo` serving the per-file and cross-file rules of every checkRepo inside it. */
24
+ export declare function withRuleMemo<T>(memo: RuleMemo, run: () => T): T;
25
+ /** The findings of per-file `rules` over `files`, from the cache when one is installed. Rules run in order, each over the files it must judge. */
26
+ export declare function memoFiles(scopeRoot: string, files: readonly string[], rules: readonly FileRule[]): CheckFinding[];
27
+ /** The value of a per-file unit of a cross-file rule, from the cache when one is installed (see `RuleMemo.unit`). */
28
+ export declare function memoUnit<T>(scopeRoot: string, id: string, file: string, extra: string, empty: T, compute: () => T): T;
29
+ /** The findings of a cross-file rule, from the cache when one is installed. */
30
+ export declare function memoKeyed(scopeRoot: string, id: string, run: () => CheckFinding[]): CheckFinding[];
@@ -0,0 +1,24 @@
1
+ let active = null;
2
+ /** Runs `run` with `memo` serving the per-file and cross-file rules of every checkRepo inside it. */
3
+ export function withRuleMemo(memo, run) {
4
+ const before = active;
5
+ active = memo;
6
+ try {
7
+ return run();
8
+ }
9
+ finally {
10
+ active = before;
11
+ }
12
+ }
13
+ /** The findings of per-file `rules` over `files`, from the cache when one is installed. Rules run in order, each over the files it must judge. */
14
+ export function memoFiles(scopeRoot, files, rules) {
15
+ return active ? active.files(scopeRoot, files, rules) : rules.flatMap((rule) => rule.run([...files]));
16
+ }
17
+ /** The value of a per-file unit of a cross-file rule, from the cache when one is installed (see `RuleMemo.unit`). */
18
+ export function memoUnit(scopeRoot, id, file, extra, empty, compute) {
19
+ return active ? active.unit(scopeRoot, id, file, extra, empty, compute) : compute();
20
+ }
21
+ /** The findings of a cross-file rule, from the cache when one is installed. */
22
+ export function memoKeyed(scopeRoot, id, run) {
23
+ return active ? active.keyed(scopeRoot, id, run) : run();
24
+ }
@@ -14,5 +14,11 @@ import type { Parsed } from './rule-types.mjs';
14
14
  /** The failure codes the finders above emit, so `starci app check` ships their labels (check.mjs adds them to CHECK_CODES). Append-only; the coordinator regenerates it at merge time. */
15
15
  export declare const A15_CODES: string[];
16
16
  export declare const FINDERS: (typeof feBuildArgsFindings | typeof featureFileFindings | typeof assetsFindings | typeof unusedSymbolFindings | typeof databaseBaselineFindings | typeof databaseConfigFindings | typeof docFreshFindings | typeof releaseProvenanceFindings | typeof secretCustodyOnlyFindings | typeof scriptHygieneFindings | typeof supplyChainFindings | typeof liteProofLaneFindings)[];
17
+ /**
18
+ * The finders the result cache keeps by the files they read: each reads the tracked tree only through `readText` / `readJson` (rules/read.mts) and the tracked
19
+ * path lists it is given, so its findings are a function of those. `check-cache-structure-rules.spec.mts` proves every member under a file-system recorder
20
+ * and fails on a read it does not go through the gateway. A finder that stats, lists or walks the work tree (stacks-release, the stacks gate) is not a member.
21
+ */
22
+ export declare const KEYED_FINDERS: ReadonlySet<(context: Parsed) => Parsed>;
17
23
  /** Findings of every alpha.15 content rule over one repository. */
18
24
  export declare function a15Findings(context: Parsed): any[];
@@ -38,6 +38,7 @@ import { ciCustodyFindings } from './ci-custody.mjs';
38
38
  import { workflowContextsFindings } from './workflow-contexts.mjs';
39
39
  import { ciStandardAppFindings } from './ci-standard.mjs';
40
40
  import { feBuildArgsFindings } from './fe-build-args.mjs';
41
+ import { memoKeyed } from '../rule-memo.mjs';
41
42
  // finders-import-end
42
43
  /** The failure codes the finders above emit, so `starci app check` ships their labels (check.mjs adds them to CHECK_CODES). Append-only; the coordinator regenerates it at merge time. */
43
44
  export const A15_CODES = [
@@ -90,15 +91,23 @@ export const FINDERS = [
90
91
  ciCustodyFindings,
91
92
  // finders-end
92
93
  ];
94
+ /**
95
+ * The finders the result cache keeps by the files they read: each reads the tracked tree only through `readText` / `readJson` (rules/read.mts) and the tracked
96
+ * path lists it is given, so its findings are a function of those. `check-cache-structure-rules.spec.mts` proves every member under a file-system recorder
97
+ * and fails on a read it does not go through the gateway. A finder that stats, lists or walks the work tree (stacks-release, the stacks gate) is not a member.
98
+ */
99
+ export const KEYED_FINDERS = new Set([docFreshFindings, scriptHygieneFindings, databaseConfigFindings, feBuildArgsFindings]);
93
100
  /** Findings of every alpha.15 content rule over one repository. */
94
101
  export function a15Findings(context) {
95
102
  // `starci app check` always passes the full context; a partial one (a finder spec driving the registry with only the fields its rule reads) must not crash the finders of other rules.
96
103
  const full = Boolean(context.repoRoot && context.repo && context.resolver && context.manifest);
97
104
  return FINDERS.flatMap((finder) => {
105
+ const run = () => finder(context);
106
+ const once = KEYED_FINDERS.has(finder) && full ? () => memoKeyed(context.repoRoot, finder.name, run) : run;
98
107
  if (full)
99
- return finder(context);
108
+ return once();
100
109
  try {
101
- return finder(context);
110
+ return once();
102
111
  }
103
112
  catch {
104
113
  return [];
@@ -149,7 +149,7 @@ function fullHistory(doc, say) {
149
149
  for (const [id, job] of jobsOf(doc)) {
150
150
  const steps = stepsOf(job);
151
151
  const base = steps.findIndex((step) => /STARCI_BASE_REF=.*>>\s*"?\$GITHUB_ENV"?/.test(runOf(step)));
152
- const lint = steps.findIndex((step) => /\bnpm run lint\b|\bstarci app lint\b/.test(runOf(step)));
152
+ const lint = steps.findIndex((step) => /\bnpm run lint\b|\bapp check\b[^\n]*--gate[ =]lint\b/.test(runOf(step)));
153
153
  if (lint >= 0 && (base < 0 || base > lint))
154
154
  out.push(say('full-history-base-ref', `job ${id} must write STARCI_BASE_REF to GITHUB_ENV before its first lint step`));
155
155
  }
@@ -174,7 +174,7 @@ function sonarLintPerSide(doc, say) {
174
174
  for (const [id, job] of jobsOf(doc)) {
175
175
  for (const step of stepsOf(job)) {
176
176
  const run = runOf(step);
177
- if (/\b(?:npm run lint|starci(?:\.mjs)?["']?\s+app lint|app lint)\b/.test(run) && /\s--out\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
177
+ if (/\b(?:npm run lint|app check)\b/.test(run) && /\s--out\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
178
178
  out.push(say('sonar-lint-per-side', `job ${id}, step ${step.name ?? 'lint'}: a lint with a Sonar import must name its side (--side be|fe) and the reports are merged afterwards; a combined lint with Sonar breaks the standard`));
179
179
  }
180
180
  }
@@ -1,9 +1,7 @@
1
1
  import type { Parsed, Repo } from './rule-types.mjs';
2
2
  /** The npm workspaces of every app, in this order. */
3
3
  export declare const WORKSPACES: readonly string[];
4
- /** The lint script of every fe workspace: the one lint of the app, scoped to the workspace. */
5
- export declare const WORKSPACE_LINT = "starci app lint --workspace .";
6
- /** The scripts of every fe app workspace, exactly: turbo runs build, dev, lint and typecheck; start serves a build. */
4
+ /** The scripts of every fe app workspace, exactly: turbo runs build, dev and typecheck; start serves a build. The lint is the root's (`starci app check --gate lint`), never a workspace script. */
7
5
  export declare const FE_APP_SCRIPTS: Readonly<Record<string, string>>;
8
6
  /** The package manager every app root declares (turbo reads it to parse the lockfile); the starci app scaffold writes it. */
9
7
  export declare const PACKAGE_MANAGER = "npm@11.6.2";
@@ -22,12 +22,10 @@ const MONO_NEST_PROJECTS = 'HFS_MONO_NEST_PROJECTS';
22
22
  const MONO_WORKSPACE_DEP = 'HFS_MONO_WORKSPACE_DEP';
23
23
  /** The npm workspaces of every app, in this order. */
24
24
  export const WORKSPACES = Object.freeze(['fe/apps/*', 'fe/packages/*']);
25
- /** The lint script of every fe workspace: the one lint of the app, scoped to the workspace. */
26
- export const WORKSPACE_LINT = 'starci app lint --workspace .';
27
- /** The scripts of every fe app workspace, exactly: turbo runs build, dev, lint and typecheck; start serves a build. */
28
- export const FE_APP_SCRIPTS = Object.freeze({ build: 'next build', dev: 'next dev', lint: WORKSPACE_LINT, start: 'next start', typecheck: 'tsc --noEmit' });
29
- /** The scripts every fe package workspace has (its build and typecheck commands are its own; its lint is the workspace lint). */
30
- const FE_PACKAGE_SCRIPTS = Object.freeze(['build', 'typecheck', 'lint']);
25
+ /** The scripts of every fe app workspace, exactly: turbo runs build, dev and typecheck; start serves a build. The lint is the root's (`starci app check --gate lint`), never a workspace script. */
26
+ export const FE_APP_SCRIPTS = Object.freeze({ build: 'next build', dev: 'next dev', start: 'next start', typecheck: 'tsc --noEmit' });
27
+ /** The scripts every fe package workspace has (its build and typecheck commands are its own); it has no lint script. */
28
+ const FE_PACKAGE_SCRIPTS = Object.freeze(['build', 'typecheck']);
31
29
  /** The package manager every app root declares (turbo reads it to parse the lockfile); the starci app scaffold writes it. */
32
30
  export const PACKAGE_MANAGER = 'npm@11.6.2';
33
31
  /** The npm name of an fe app workspace. */
@@ -61,7 +59,7 @@ function rootFindings(repoRoot) {
61
59
  if (!PACKAGE_MANAGER_FORM.test(String(pkg.packageManager ?? '')))
62
60
  findings.push(found(MONO_WORKSPACES, MANIFEST, `${MANIFEST} declares packageManager ${JSON.stringify(pkg.packageManager ?? null)}; turbo needs \`npm@<exact version>\` there.`, { packageManager: pkg.packageManager ?? null }));
63
61
  if (typeof pkg.devDependencies?.turbo !== 'string')
64
- findings.push(found(MONO_WORKSPACES, MANIFEST, `${MANIFEST} does not declare turbo in its devDependencies; the turbo task graph (turbo.json) runs build, dev, lint and typecheck over the fe workspaces.`, { missing: 'turbo' }));
62
+ findings.push(found(MONO_WORKSPACES, MANIFEST, `${MANIFEST} does not declare turbo in its devDependencies; the turbo task graph (turbo.json) runs build, dev and typecheck over the fe workspaces.`, { missing: 'turbo' }));
65
63
  return findings;
66
64
  }
67
65
  /** R144: the manifest of each fe workspace. */
@@ -73,16 +71,16 @@ function appWorkspaceScriptFindings(file, project, name, pkg, scripts) {
73
71
  const want = Object.keys(FE_APP_SCRIPTS).sort(byCodeUnit);
74
72
  const off = [...new Set([...want, ...Object.keys(scripts)])].sort(byCodeUnit).filter((key) => scripts[key] !== FE_APP_SCRIPTS[key]);
75
73
  if (off.length)
76
- findings.push(found(MONO_FE_WORKSPACE, file, `${file} has scripts ${JSON.stringify(scripts)}; an fe app workspace has exactly ${JSON.stringify(FE_APP_SCRIPTS)} (turbo runs build, dev, lint and typecheck; differs at ${off.join(', ')}).`, { scripts: off }));
74
+ findings.push(found(MONO_FE_WORKSPACE, file, `${file} has scripts ${JSON.stringify(scripts)}; an fe app workspace has exactly ${JSON.stringify(FE_APP_SCRIPTS)} (turbo runs build, dev and typecheck; the lint is the root's, \`starci app check --gate lint\`; differs at ${off.join(', ')}).`, { scripts: off }));
77
75
  return findings;
78
76
  }
79
77
  function packageWorkspaceScriptFindings(file, scripts) {
80
78
  const findings = [];
81
79
  const missing = FE_PACKAGE_SCRIPTS.filter((key) => typeof scripts[key] !== 'string' || !scripts[key]);
82
80
  if (missing.length)
83
- findings.push(found(MONO_FE_WORKSPACE, file, `${file} has no ${missing.join(', ')} script; an fe package workspace builds, type-checks and lints (turbo builds it before the apps that import it).`, { scripts: missing }));
84
- else if (scripts.lint !== WORKSPACE_LINT)
85
- findings.push(found(MONO_FE_WORKSPACE, file, `${file} lints with ${JSON.stringify(scripts.lint)}; a workspace lints with ${JSON.stringify(WORKSPACE_LINT)}, the one lint of the app scoped to it.`, { scripts: ['lint'] }));
81
+ findings.push(found(MONO_FE_WORKSPACE, file, `${file} has no ${missing.join(', ')} script; an fe package workspace builds and type-checks (turbo builds it before the apps that import it).`, { scripts: missing }));
82
+ if (scripts.lint !== undefined)
83
+ findings.push(found(MONO_FE_WORKSPACE, file, `${file} has a lint script; a workspace has none, the app is linted once from the root with \`starci app check --gate lint\`.`, { scripts: ['lint'] }));
86
84
  return findings;
87
85
  }
88
86
  function workspaceManifestFindings(repoRoot, project, workspaces) {
@@ -1,7 +1,8 @@
1
- // pipeline.mjs - HFS_CI_MISSING_CANON (R13): CI runs the pinned `starci app lint`.
2
- // .github/workflows/ci.yml a `run:` step that runs the one lint entry: `starci app lint`,
3
- // `npx starci-harness[@<version>] app lint`, or `npm run <script>` whose command runs `starci app lint`
4
- // (`npm run lint`, arguments after `--` allowed); a version named in the step is the starci-harness pin,
1
+ // pipeline.mjs - HFS_CI_MISSING_CANON (R13): CI runs the pinned lint gate of `starci app check`.
2
+ // .github/workflows/ci.yml a `run:` step that runs the lint gate: `starci app check --gate lint`,
3
+ // `npx starci-harness[@<version>] app check --gate lint`, or `npm run <script>` whose command runs it
4
+ // (`npm run lint`, arguments after `--` allowed);
5
+ // a version named in the step is the starci-harness pin,
5
6
  // the version of the CLI that runs the check (canon-pins.mjs; an installed one is pinned by the pin check, R15)
6
7
  // (the push hook is no lint or typecheck step: it checks the release gate, scripts/hfs/runtime-rules/hook-shape.mjs keeps its shape)
7
8
  // A missing file is the slot manifest's finding (HFS_SLOT_REQUIRED_MISSING); a file that is present and lacks the step is this rule's.
@@ -13,7 +14,8 @@ const CI_FILE = '.github/workflows/ci.yml';
13
14
  const hfsPackage = 'starci-harness';
14
15
  const RUN_PREFIX = /^\s*(?:-\s+)?run:/;
15
16
  const NPX_PREFIX = /^npx\s+(?:(?:--no-install|-y)\s+)?/;
16
- const LINT_COMMAND = /^(?:starci-harness(?:@(\S+))?|starci)\s+app\s+lint/;
17
+ const LINT_COMMAND = /^(?:starci-harness(?:@(\S+))?|starci)\s+app\s+check/;
18
+ const GATE_ARGUMENT = /(?:^|\s)--gate(?:=|\s+)(\S+)/g;
17
19
  const NPM_RUN_PREFIX = 'npm run ';
18
20
  const EXAMPLE_RUN_PREFIX = 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ';
19
21
  const SCRIPT_NAME = /^[\w:.-]+/;
@@ -56,7 +58,9 @@ function parseNpmRun(command) {
56
58
  const args = separator ? tail.slice(separator[0].length) : null;
57
59
  return args !== null && !LINE_TERMINATOR.test(args) ? { script, args } : null;
58
60
  }
59
- /** { version } of a `starci app lint` command (`version` undefined when no starci-harness version is named), else null. */
61
+ /** Whether the arguments of an `app check` select the lint gate: `--gate lint` (or `--gate=lint`) among them. */
62
+ const runsLintGate = (tail) => [...tail.matchAll(GATE_ARGUMENT)].some((gate) => gate[1] === 'lint');
63
+ /** { version } of a `starci app check` command that runs the lint gate (`version` undefined when no starci-harness version is named), else null. */
60
64
  function lintCommandOf(command) {
61
65
  const rest = command.replace(NPX_PREFIX, '');
62
66
  const match = LINT_COMMAND.exec(rest);
@@ -65,7 +69,7 @@ function lintCommandOf(command) {
65
69
  const tail = rest.slice(match[0].length);
66
70
  if (tail && !(WHITESPACE.test(tail[0]) && !LINE_TERMINATOR.test(tail.slice(1))))
67
71
  return null;
68
- return { version: match[1] };
72
+ return runsLintGate(tail) ? { version: match[1] } : null;
69
73
  }
70
74
  /** `npm run <script> -- <args>` spelled out as the root package.json script it runs; any other command is itself. */
71
75
  function expanded(command, scripts) {
@@ -87,9 +91,9 @@ export function pipelineFindings({ repoRoot, files, pins }) {
87
91
  const selected = isRuntimeExampleApp(repoRoot) ? commands.map(exampleCommandOf) : commands;
88
92
  const lints = selected.flatMap((command) => chainedCommands(expanded(command, scripts))).map(lintCommandOf).filter(Boolean);
89
93
  if (!lints.length)
90
- findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`starci app lint\` (directly or through \`npm run lint\`); CI runs the one lint entry, which includes the repository check`, { step: 'starci app lint' }));
94
+ findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`starci app check --gate lint\` (directly or through \`npm run lint\`); CI runs the lint gate of the one app check`, { step: 'starci app check --gate lint' }));
91
95
  else if (pinned && !lints.some((match) => match.version === undefined || match.version === pinned)) {
92
- findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs starci app lint at ${lints[0].version}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'starci app lint', pinned }));
96
+ findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs starci app check at ${lints[0].version}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'starci app check --gate lint', pinned }));
93
97
  }
94
98
  }
95
99
  return findings;
@@ -1,7 +1,12 @@
1
1
  import type { Finding, Parsed } from './rule-types.mjs';
2
+ /** Who is told which file a rule reads: the result cache records the read set of a cross-file rule through it. */
3
+ type ReadSink = (repoRoot: string, rel: string) => void;
4
+ /** Runs `run` with every `readText` / `readJson` it makes reported to `onRead` (before the read), and returns its value. */
5
+ export declare function recordReads<T>(onRead: ReadSink, run: () => T): T;
2
6
  /** The text of `rel` under `repoRoot`, or null when it is absent, unreadable, over MAX_READ_BYTES or binary. */
3
7
  export declare function readText(repoRoot: string, rel: string): string | null;
4
8
  /** The JSON of `rel`, or null when it is absent or not JSON. */
5
9
  export declare function readJson(repoRoot: string, rel: string): Parsed | null;
6
10
  /** One error finding of `code` on `file`; `extra` carries the finding's own fields. */
7
11
  export declare const found: (code: string, file: string, message: string, extra?: Record<string, unknown>) => Finding;
12
+ export {};
@@ -3,8 +3,21 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  /** Files above this size are not read by a tree check (a tracked binary or a generated dump is no source of these laws). */
5
5
  const MAX_READ_BYTES = 1024 * 1024;
6
+ let sink = null;
7
+ /** Runs `run` with every `readText` / `readJson` it makes reported to `onRead` (before the read), and returns its value. */
8
+ export function recordReads(onRead, run) {
9
+ const before = sink;
10
+ sink = onRead;
11
+ try {
12
+ return run();
13
+ }
14
+ finally {
15
+ sink = before;
16
+ }
17
+ }
6
18
  /** The text of `rel` under `repoRoot`, or null when it is absent, unreadable, over MAX_READ_BYTES or binary. */
7
19
  export function readText(repoRoot, rel) {
20
+ sink?.(repoRoot, rel);
8
21
  try {
9
22
  const target = path.join(repoRoot, rel);
10
23
  if (fs.statSync(target).size > MAX_READ_BYTES)
@@ -17,6 +17,8 @@ export declare function secretFileFindings({ file, text, manifestText }: {
17
17
  text: string | null;
18
18
  manifestText?: string | null | undefined;
19
19
  }): import("./rule-types.mjs").Finding[];
20
+ /** The application-stacks manifest beside a README (the one other file a README's secret findings read), in the README's own scope; null for any other file. */
21
+ export declare const readmeManifestOf: (file: string) => string[] | null;
20
22
  /** The findings of R06 over `files` (tracked paths) of `repoRoot`; `resolver` names the slots. */
21
23
  export declare function secretFindings({ repoRoot, files, resolver }: {
22
24
  repoRoot: string;
@@ -51,6 +51,8 @@ export function secretFileFindings({ file, text, manifestText }) {
51
51
  }
52
52
  return findings;
53
53
  }
54
+ /** The application-stacks manifest beside a README (the one other file a README's secret findings read), in the README's own scope; null for any other file. */
55
+ export const readmeManifestOf = (file) => (/(?:^|\/)README\.md$/.test(file) ? [path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')] : null);
54
56
  /** The findings of R06 over `files` (tracked paths) of `repoRoot`; `resolver` names the slots. */
55
57
  export function secretFindings({ repoRoot, files, resolver }) {
56
58
  const findings = [];
@@ -61,7 +63,8 @@ export function secretFindings({ repoRoot, files, resolver }) {
61
63
  findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${destination}`, { slot: slot.slot, goesTo: slot.goesTo }));
62
64
  continue;
63
65
  }
64
- const manifestText = /(?:^|\/)README\.md$/.test(file) ? readText(repoRoot, path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')) : undefined;
66
+ const manifestPath = readmeManifestOf(file)?.[0];
67
+ const manifestText = manifestPath === undefined ? undefined : readText(repoRoot, manifestPath);
65
68
  findings.push(...secretFileFindings({ file, text: readText(repoRoot, file), manifestText }));
66
69
  }
67
70
  return findings;
@@ -6,6 +6,7 @@ import { loadTypescript } from '../../lib/package-at.mjs';
6
6
  import { walkSqlAst, sqlParser, sqlCallArgument } from '../../lib/sql-ast.mjs';
7
7
  import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
8
8
  import { found, readText } from './read.mjs';
9
+ import { memoUnit } from '../rule-memo.mjs';
9
10
  import { stacksDeploymentContexts } from './stacks-runtime.mjs';
10
11
  // PostgreSQL initializes asynchronously, while HFS finders must stay synchronous.
11
12
  // Optional loading also lets dependency-free relocated development gates import this rule.
@@ -39,20 +40,26 @@ function safeRelative(relative) {
39
40
  return false;
40
41
  return relative.split('/').every((part) => part && part !== '.' && part !== '..');
41
42
  }
42
- function publicReference(repoRoot, files, relative) {
43
+ /** `links` remembers which directories are symbolic links for the duration of one scan, so a directory shared by many files is asked once. */
44
+ function publicReference(repoRoot, files, relative, links = new Map()) {
43
45
  if (!repoRoot || !safeRelative(relative))
44
46
  return false;
45
- if (!files.includes(relative))
47
+ if (!(files instanceof Set ? files.has(relative) : files.includes(relative)))
46
48
  return false;
47
49
  const root = path.resolve(repoRoot), target = path.resolve(root, relative);
48
50
  try {
49
- let cursor = target;
50
- while (cursor !== root) {
51
- if (fs.lstatSync(cursor).isSymbolicLink())
51
+ const stat = fs.lstatSync(target);
52
+ if (stat.isSymbolicLink())
53
+ return false;
54
+ for (let cursor = path.dirname(target); cursor !== root; cursor = path.dirname(cursor)) {
55
+ let link = links.get(cursor);
56
+ if (link === undefined) {
57
+ link = fs.lstatSync(cursor).isSymbolicLink();
58
+ links.set(cursor, link);
59
+ }
60
+ if (link)
52
61
  return false;
53
- cursor = path.dirname(cursor);
54
62
  }
55
- const stat = fs.lstatSync(target);
56
63
  return stat.isFile() && stat.size > 0 && stat.size <= MAX_BYTES;
57
64
  }
58
65
  catch {
@@ -356,30 +363,37 @@ function templateObservations(node, file) {
356
363
  return [{ id: null, cadence: null, at: file }];
357
364
  return sqlObservations(named, file);
358
365
  }
359
- function recurringObservations({ doc, repoRoot, files, file, issues }) {
360
- const sql = file.startsWith('db/migrations/') && file.endsWith('.sql') && !file.startsWith('db/migrations/archive/');
361
- const sourceFile = SOURCE.test(file) && !TEST.test(file);
362
- if ((!sql && !sourceFile) || !publicReference(repoRoot, files, file))
363
- return [];
366
+ function observationsOf({ doc, repoRoot, file, sql }) {
364
367
  const text = readText(repoRoot, file);
365
368
  if (text === null)
366
- return [];
369
+ return { observations: [], issues: [] };
367
370
  if (sql)
368
- return sqlObservations(text, file);
371
+ return { observations: sqlObservations(text, file), issues: [] };
369
372
  ts ??= loadTypescript(repoRoot, HERE);
370
- if (!ts) {
371
- issues.push(scheduleIssue(file, 'TypeScript is required to inspect recurring application work'));
372
- return [];
373
- }
373
+ if (!ts)
374
+ return { observations: [], issues: [scheduleIssue(file, 'TypeScript is required to inspect recurring application work')] };
374
375
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
375
- issues.push(...sourceObservationIssues(doc, file, source, sourceBindings(source)));
376
- return sourceSqlObservations(source, file);
376
+ const issues = sourceObservationIssues(doc, file, source, sourceBindings(source));
377
+ return { observations: sourceSqlObservations(source, file), issues };
378
+ }
379
+ function recurringObservations({ doc, repoRoot, files, file, issues, links, tools }) {
380
+ const sql = file.startsWith('db/migrations/') && file.endsWith('.sql') && !file.startsWith('db/migrations/archive/');
381
+ const sourceFile = SOURCE.test(file) && !TEST.test(file);
382
+ if ((!sql && !sourceFile) || !publicReference(repoRoot, files, file, links))
383
+ return [];
384
+ // The result cache keeps this per file: the bytes of the file, the manifest and the parsers decide it (contexts/project/check-cache.md).
385
+ const one = memoUnit(repoRoot, 'recurring-observations', file, `${tools}
386
+ ${JSON.stringify(doc)}`, { observations: [], issues: [] }, () => observationsOf({ doc, repoRoot, file, sql }));
387
+ issues.push(...one.issues);
388
+ return one.observations;
377
389
  }
378
390
  function recurringSourceIssues({ doc, repoRoot, files }) {
379
391
  const issues = [], cron = new Map();
380
392
  const paths = [...new Set(files)].sort((left, right) => left.localeCompare(right));
393
+ const tracked = new Set(paths), links = new Map();
394
+ const tools = `typescript=${loadTypescript(repoRoot, HERE) !== null} sql=${pg !== null}`;
381
395
  for (const file of paths) {
382
- const observations = recurringObservations({ doc, repoRoot, files, file, issues });
396
+ const observations = recurringObservations({ doc, repoRoot, files: tracked, file, issues, links, tools });
383
397
  for (const observation of observations) {
384
398
  if ('remove' in observation) {
385
399
  if (observation.remove)
@@ -0,0 +1,35 @@
1
+ import type { TypeScript } from './ts-ast.mjs';
2
+ /** What the declaration emit cannot write without reading other files, and where: one export (or one part of it) the author must annotate. */
3
+ export interface SurfaceProblem {
4
+ /** The TypeScript diagnostic code (9007: function without a return type, 9010: variable without a type, ...). */
5
+ code: number;
6
+ /** The offset and length of the offending span in the file's text. */
7
+ start: number;
8
+ length: number;
9
+ /** The name of the export (or member) the span sits in, or `default export`. */
10
+ name: string;
11
+ /** What to annotate: `return type`, `variable type`, `parameter type`, `property type`, `accessor type`, `destructured export` or `exported value`. */
12
+ annotate: string;
13
+ /** TypeScript's own reason, one sentence. */
14
+ reason: string;
15
+ }
16
+ /** The declaration emit of one file: its text (the digest of the type surface) and the exports it could not write explicitly. */
17
+ export interface DeclarationSurface {
18
+ outputText: string;
19
+ problems: SurfaceProblem[];
20
+ }
21
+ /** A script file (`.js`, `.mjs`, `.cjs`, `.jsx`): it has no declaration emit and stands for its content. */
22
+ export declare const SCRIPT: RegExp;
23
+ /** A declaration file: it is its own surface. */
24
+ export declare const DECLARATION: RegExp;
25
+ /** A TypeScript source file whose surface the declaration emit writes. */
26
+ export declare const TYPESCRIPT: RegExp;
27
+ /** Whether `file` is a TypeScript source (not a declaration file) the declaration emit reads. */
28
+ export declare const isEmittableSource: (file: string) => boolean;
29
+ /**
30
+ * The declaration emit of one TypeScript source and the exports it could not write explicitly (a type inferred from bodies and other files), or null
31
+ * when the compiler has no `transpileDeclaration` (before TypeScript 5.5). A declaration whose only diagnostics are components returning JSX has the
32
+ * surface its emit shows. `module` is the side's `module` compiler option, which the emit reads. A throw from the compiler propagates: the caller
33
+ * decides what an unreadable file means.
34
+ */
35
+ export declare function declarationSurface(ts: TypeScript, file: string, text: string, module?: number): DeclarationSurface | null;
@@ -0,0 +1,112 @@
1
+ /** A script file (`.js`, `.mjs`, `.cjs`, `.jsx`): it has no declaration emit and stands for its content. */
2
+ export const SCRIPT = /\.(?:[cm]?jsx?)$/;
3
+ /** A declaration file: it is its own surface. */
4
+ export const DECLARATION = /\.d\.[cm]?ts$/;
5
+ /** A TypeScript source file whose surface the declaration emit writes. */
6
+ export const TYPESCRIPT = /\.(?:[cm]?tsx?)$/;
7
+ /** Whether `file` is a TypeScript source (not a declaration file) the declaration emit reads. */
8
+ export const isEmittableSource = (file) => TYPESCRIPT.test(file) && !DECLARATION.test(file);
9
+ /** Whether `expression` is a JSX element, fragment, `null` or `undefined`, or a conditional of those: its type does not depend on any other file. */
10
+ function isJsxValue(ts, expression) {
11
+ if (expression === undefined)
12
+ return false;
13
+ let node = expression;
14
+ while (ts.isParenthesizedExpression(node))
15
+ node = node.expression;
16
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) || node.kind === ts.SyntaxKind.NullKeyword)
17
+ return true;
18
+ if (ts.isIdentifier(node))
19
+ return node.text === 'undefined';
20
+ return ts.isConditionalExpression(node) && isJsxValue(ts, node.whenTrue) && isJsxValue(ts, node.whenFalse);
21
+ }
22
+ /**
23
+ * Whether the function (or method) the declaration diagnostic at `start` points at has the type `JSX.Element | null | undefined` whatever its body
24
+ * reads: not async, not a generator, and every `return` of its own (and its concise body) a JSX value. A React component written without a return
25
+ * type is the common case; its inferred type is one the JSX namespace of the installed packages gives, which a lockfile change dirties as a whole.
26
+ */
27
+ function returnsOnlyJsx(ts, source, start) {
28
+ let found;
29
+ const find = (node) => {
30
+ if (found !== undefined || start < node.getFullStart() || start >= node.end)
31
+ return;
32
+ const named = (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name !== undefined && node.name.getStart(source) === start;
33
+ if (named || ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.getStart(source) === start)) {
34
+ found = node;
35
+ return;
36
+ }
37
+ ts.forEachChild(node, find);
38
+ };
39
+ find(source);
40
+ const fn = found;
41
+ if (fn === undefined || fn.type !== undefined || fn.body === undefined)
42
+ return false;
43
+ if (fn.asteriskToken !== undefined || (ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0)
44
+ return false;
45
+ if (!ts.isBlock(fn.body))
46
+ return isJsxValue(ts, fn.body);
47
+ let only = true;
48
+ const visit = (node) => {
49
+ if (!only || ts.isFunctionLike(node) || ts.isClassLike(node))
50
+ return;
51
+ if (ts.isReturnStatement(node) && node.expression !== undefined && !isJsxValue(ts, node.expression))
52
+ only = false;
53
+ ts.forEachChild(node, visit);
54
+ };
55
+ ts.forEachChild(fn.body, visit);
56
+ return only;
57
+ }
58
+ /** What to annotate for each isolated-declaration diagnostic code; any other code asks for the exported value's type. */
59
+ const ANNOTATE = new Map([
60
+ [9007, 'return type'], [9008, 'return type'], [9009, 'accessor type'], [9010, 'variable type'], [9011, 'parameter type'], [9012, 'property type'],
61
+ [9019, 'destructured export'], [9025, 'parameter type'],
62
+ ]);
63
+ /** The declaration kinds whose name labels the export a diagnostic sits in, innermost first. */
64
+ function nameOf(ts, source, start) {
65
+ let innermost = source;
66
+ const descend = (node) => {
67
+ if (start < node.getFullStart() || start >= node.end)
68
+ return;
69
+ innermost = node;
70
+ ts.forEachChild(node, descend);
71
+ };
72
+ ts.forEachChild(source, descend);
73
+ for (let node = innermost; node !== undefined && node !== source; node = node.parent) {
74
+ if (ts.isExportAssignment(node))
75
+ return 'default export';
76
+ if (ts.isBindingElement(node) && ts.isIdentifier(node.name))
77
+ return node.name.text;
78
+ if ((ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isPropertyAssignment(node) || ts.isGetAccessorDeclaration(node)) && node.name !== undefined && ts.isIdentifier(node.name))
79
+ return node.name.text;
80
+ if (ts.isFunctionDeclaration(node) && (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Default) !== 0)
81
+ return 'default export';
82
+ }
83
+ return 'default export';
84
+ }
85
+ /**
86
+ * The declaration emit of one TypeScript source and the exports it could not write explicitly (a type inferred from bodies and other files), or null
87
+ * when the compiler has no `transpileDeclaration` (before TypeScript 5.5). A declaration whose only diagnostics are components returning JSX has the
88
+ * surface its emit shows. `module` is the side's `module` compiler option, which the emit reads. A throw from the compiler propagates: the caller
89
+ * decides what an unreadable file means.
90
+ */
91
+ export function declarationSurface(ts, file, text, module) {
92
+ const emit = ts.transpileDeclaration;
93
+ if (emit === undefined)
94
+ return null;
95
+ const declared = emit.call(ts, text, { compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, ...(module === undefined ? {} : { module }) }, fileName: file, reportDiagnostics: true });
96
+ const diagnostics = (declared.diagnostics ?? []);
97
+ if (diagnostics.length === 0)
98
+ return { outputText: declared.outputText, problems: [] };
99
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
100
+ const problems = [];
101
+ for (const diagnostic of diagnostics) {
102
+ const exempt = (diagnostic.code === 9007 || diagnostic.code === 9008) && diagnostic.start !== undefined && returnsOnlyJsx(ts, source, diagnostic.start);
103
+ if (exempt)
104
+ continue;
105
+ const start = diagnostic.start ?? 0;
106
+ problems.push({
107
+ code: diagnostic.code, start, length: diagnostic.length ?? 0, name: nameOf(ts, source, start), annotate: ANNOTATE.get(diagnostic.code) ?? 'exported value',
108
+ reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n').split('\n')[0] ?? '',
109
+ });
110
+ }
111
+ return { outputText: declared.outputText, problems };
112
+ }
@@ -5,7 +5,7 @@
5
5
  // <repo>/.starciwork/worktrees/<name> one runtime scratch tree
6
6
  // The directory is git-excluded (.git/info/exclude), so `git status`,
7
7
  // `git ls-files --others --exclude-standard` and sonar never see it; every scanner that walks the filesystem
8
- // (canon-scan's eslint, starci app lint, the architecture walkers, input digests, proof-integrity, Work readers)
8
+ // (canon-scan's eslint, starci app check, the architecture walkers, input digests, proof-integrity, Work readers)
9
9
  // skips it with isWorktreesPath / WORKTREES_IGNORE_GLOBS, or it would lint and hash every sibling worktree.
10
10
  import path from 'node:path';
11
11
  import { posixPath } from './path-key.mjs';
package/scaffold/app.mjs CHANGED
@@ -21,7 +21,7 @@ import { HARNESS_PACKAGE, canonPins } from '../runtime/scripts/hfs/canon-pins.mj
21
21
  import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, writeTargets } from '../sync/index.mjs';
22
22
  import { ScaffoldError } from './service.mjs';
23
23
  import { testWorldFiles } from './test-world.mjs';
24
- import { FE_APP_SCRIPTS, PACKAGE_MANAGER, WORKSPACES, WORKSPACE_LINT, feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
24
+ import { FE_APP_SCRIPTS, PACKAGE_MANAGER, WORKSPACES, feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
25
25
  import { isLinkLike } from '../runtime/scripts/api/fs/is-link-like.mjs';
26
26
  import { unlinkOnly } from '../runtime/scripts/api/fs/lib.mjs';
27
27
  import { initializeAppIdentity } from '../runtime/scripts/hfs/secret-init.mjs';
@@ -200,8 +200,8 @@ const FE_PACKAGES = Object.freeze({
200
200
  ui: { exports: ['.'], dependencies: { '@heroui/react': null, '@starci/grammar': null, '@starci/heroicons': null, '@xyflow/react': null, 'react-aria-components': null, 'next-intl': null, react: null, 'react-dom': null } },
201
201
  i18n: { exports: ['.', './proxy', './routing'], dependencies: { next: null, 'next-intl': null, 'server-only': null } },
202
202
  });
203
- /** The scripts of every fe package workspace: tsc builds it to dist and type-checks it; it lints with the workspace lint. */
204
- const PACKAGE_WORKSPACE_SCRIPTS = Object.freeze({ build: 'tsc -p tsconfig.build.json', lint: WORKSPACE_LINT, typecheck: 'tsc --noEmit -p tsconfig.json' });
203
+ /** The scripts of every fe package workspace: tsc builds it to dist and type-checks it. */
204
+ const PACKAGE_WORKSPACE_SCRIPTS = Object.freeze({ build: 'tsc -p tsconfig.build.json', typecheck: 'tsc --noEmit -p tsconfig.json' });
205
205
  /** The app hfs.json of a new app called `name`; full remains the byte-for-byte default declaration. */
206
206
  export const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
207
207
  hfs: manifest.major,