@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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.0
4
+
5
+ - Added: `starci app codemod explicit-surface` writes the inferred type of every export the classifier counts as open (R368): return types, variable, parameter and property types, from the app's own TypeScript program. It prefers a named type the program exports, writes `ReactElement` for components and Next's `Metadata` for `generateMetadata`, adds merged type-only imports after a directive prologue, compiles the result beside the original and writes a file only when it gains no diagnostic, and reports (with a suggested fix) destructured exports and anonymous, computed, too long, `any`, unnameable and predicate types. Prettier of the app formats what is written.
6
+ - Added: `starci app codemod directive-first` moves a `"use client"` or `"use server"` below another statement, or in the form `("use client")`, to the first statement of its module (R369).
7
+ - Changed: the test-world declaration (R47, R89) is read in its typed form, `const world: TestWorldApi = defineTestWorld({ ... })` with `useTestWorld` and `useSandbox` exported typed from it, as well as in the destructured form; the template writes the typed form, and a config with neither is still refused.
8
+ - Added: `template-surface.spec.mts` holds every TypeScript template to R368 and R369 (the front-end units are judged through their generators).
9
+ - Changed: the scaffold templates write the type of every export (R368): Nest module definitions export each `ConfigurableModuleHost` member with its type, hooks name their result interface, async pages and layouts return `Promise<ReactElement>`, redux, zustand, next-intl and Apollo exports are typed, SQL texts are `SqlText`, entity and migration lists are `DatabaseConnectionOptions` members. A new app scaffolds clean under `explicit-export-surface`.
10
+ - Added: `runtime/scripts/lib/declaration-surface.mjs`, the one classifier of a TypeScript file's exported type surface (the isolated declaration emit and the exports it cannot write without inference, with the JSX-only component exemption), and `runtime/scripts/hfs/explicit-surface-rule.mjs`, the factory of the `explicit-export-surface` rule both lint canons build (R368). The app-check ESLint cache reads the same classifier for `open`, so the rule reports exactly the files the cache counts as open.
11
+ - Changed: the ESLint child of the cached lint hands its project-graph snapshot out. The list runner (`lint/eslint-list.cjs`, now also the runner of a whole-side run, as the list `.`) asks each installed canon for `projectGraphSnapshot` after the lint and writes the findings beside its results; `lintSide` writes the `graph` records from them and builds the graph itself only for a canon the child did not answer for. A whole-side lint no longer builds the project graph a second time in the CLI process.
12
+
3
13
  ## 5.0.8
4
14
 
5
15
  - Removed: the `starci app lint` verb. `starci app check` is the one verb for app quality and carries what the lint verb carried: `--side be|fe` limits the lint gate to one side (reading and writing that side's cache records), `--out <file>` writes the merged Sonar Generic Issue Import document of the run (the findings of the selected gates under `starci-hfs`, then ESLint and stylelint), and `--fix` runs ESLint and stylelint with `--fix` before the check (with `--gate lint`). There is no `--changed` and no `--workspace`: the cache makes a whole-app check of an unchanged tree take seconds. The rendered `lint` and `lint:fix` scripts, the managed CI lint step and the release gate's Sonar row call `app check --gate lint`, and rule R13 requires that form.
@@ -6,6 +6,7 @@
6
6
  // added or removed file and every file that reaches one through imports, in the new graph and in the recorded one. Everything here is pure over
7
7
  // its inputs (the compiler, a file reader, the hashes), so a spec drives it with fixtures and the cache itself stays in lint-family.mts.
8
8
  import path from 'node:path';
9
+ import { DECLARATION, SCRIPT, TYPESCRIPT, declarationSurface } from '../runtime/scripts/lib/declaration-surface.mjs';
9
10
  import { hashText } from './hash.mjs';
10
11
  export const posix = (file) => file.replace(/\\/g, '/').replace(/^\.\//, '');
11
12
  /** The compiler options of the side's own tsconfig (the file the typed ESLint run reads), or the permissive defaults when the side holds none. */
@@ -120,58 +121,6 @@ export function resolveImports(host, options, file, text) {
120
121
  }
121
122
  return [...found].sort();
122
123
  }
123
- /** Whether `expression` is a JSX element, fragment, `null` or `undefined`, or a conditional of those: its type does not depend on any other file. */
124
- function isJsxValue(ts, expression) {
125
- if (expression === undefined)
126
- return false;
127
- let node = expression;
128
- while (ts.isParenthesizedExpression(node))
129
- node = node.expression;
130
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) || node.kind === ts.SyntaxKind.NullKeyword)
131
- return true;
132
- if (ts.isIdentifier(node))
133
- return node.text === 'undefined';
134
- return ts.isConditionalExpression(node) && isJsxValue(ts, node.whenTrue) && isJsxValue(ts, node.whenFalse);
135
- }
136
- /**
137
- * Whether the function (or method) the declaration diagnostic at `start` points at has the type `JSX.Element | null | undefined` whatever its body
138
- * 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
139
- * 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.
140
- */
141
- function returnsOnlyJsx(ts, source, start) {
142
- let found;
143
- const find = (node) => {
144
- if (found !== undefined || start < node.getFullStart() || start >= node.end)
145
- return;
146
- const named = (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name !== undefined && node.name.getStart(source) === start;
147
- if (named || ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.getStart(source) === start)) {
148
- found = node;
149
- return;
150
- }
151
- ts.forEachChild(node, find);
152
- };
153
- find(source);
154
- const fn = found;
155
- if (fn === undefined || fn.type !== undefined || fn.body === undefined)
156
- return false;
157
- if (fn.asteriskToken !== undefined || (ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0)
158
- return false;
159
- if (!ts.isBlock(fn.body))
160
- return isJsxValue(ts, fn.body);
161
- let only = true;
162
- const visit = (node) => {
163
- if (!only || ts.isFunctionLike(node) || ts.isClassLike(node))
164
- return;
165
- if (ts.isReturnStatement(node) && node.expression !== undefined && !isJsxValue(ts, node.expression))
166
- only = false;
167
- ts.forEachChild(node, visit);
168
- };
169
- ts.forEachChild(fn.body, visit);
170
- return only;
171
- }
172
- const SCRIPT = /\.(?:[cm]?jsx?)$/;
173
- const DECLARATION = /\.d\.[cm]?ts$/;
174
- const TYPESCRIPT = /\.(?:[cm]?tsx?)$/;
175
124
  /**
176
125
  * The type surface of one source file: the digest of its isolated declaration emit (`ts.transpileDeclaration`, TypeScript 5.5 and later, which reads the
177
126
  * file alone) and whether it is open. A body-only edit leaves the emit, and so the digest, as it was; a changed signature, export list or doc comment
@@ -184,16 +133,12 @@ export function surfaceOf(host, options, file, text) {
184
133
  return { api: hashText(text), open: true, refs: undefined };
185
134
  if (!TYPESCRIPT.test(file))
186
135
  return { api: hashText(text), open: false, refs: [] };
187
- const emit = ts.transpileDeclaration;
188
- if (emit === undefined)
189
- return { api: undefined, open: true, refs: undefined };
190
136
  try {
191
- const declared = emit.call(ts, text, { compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, ...(options.module === undefined ? {} : { module: options.module }) }, fileName: file, reportDiagnostics: true });
192
- const diagnostics = (declared.diagnostics ?? []);
193
- // a declaration whose only diagnostics are components returning JSX has the surface its emit shows
194
- const source = diagnostics.length === 0 ? undefined : ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
195
- const inferred = source !== undefined && !diagnostics.every((diagnostic) => (diagnostic.code === 9007 || diagnostic.code === 9008) && diagnostic.start !== undefined && returnsOnlyJsx(ts, source, diagnostic.start));
196
- return { api: hashText(declared.outputText), open: inferred, refs: resolveImports(host, options, file, declared.outputText) };
137
+ const surface = declarationSurface(ts, file, text, options.module);
138
+ if (surface === null)
139
+ return { api: undefined, open: true, refs: undefined };
140
+ // the classifier is the one the explicit-export-surface rule of the canons reports with: a file is open exactly when it has a problem
141
+ return { api: hashText(surface.outputText), open: surface.problems.length > 0, refs: resolveImports(host, options, file, surface.outputText) };
197
142
  }
198
143
  catch {
199
144
  return { api: undefined, open: true, refs: undefined };
@@ -78,10 +78,14 @@ export interface StylelintResult {
78
78
  warnings: StylelintWarning[];
79
79
  [key: string]: unknown;
80
80
  }
81
- /** The answer of a linter seam: validated results of the files it was asked about, or why it could not complete. */
81
+ /**
82
+ * The answer of a linter seam: validated results of the files it was asked about, or why it could not complete. `graph` is, for ESLint, the project-graph
83
+ * snapshot each canon's graph rules built in the child, by canon name: the records of the `graph` step are written from it instead of building the graph again.
84
+ */
82
85
  export interface LinterOutcome<R> {
83
86
  results: R[];
84
87
  errors: string[];
88
+ graph?: Readonly<Record<string, GraphSnapshot>> | undefined;
85
89
  }
86
90
  /** Everything one side's cached lint needs. */
87
91
  export interface LintSideInput {
@@ -147,6 +151,11 @@ export declare function crossDigestFactory(options: {
147
151
  }): (file: string) => string | undefined;
148
152
  /** The TypeScript the app installed, resolved from the side folder like the linters are (not this package's own copy). */
149
153
  export declare function loadAppTypeScript(sideRoot: string): Ts;
154
+ /**
155
+ * The graph findings of every canon that publishes graph rules, merged by file and sorted. A canon whose snapshot the ESLint child handed out (`handed`, by
156
+ * canon name) is not asked to build its graph again; any other canon builds it through its `projectGraph`.
157
+ */
158
+ export declare function mergedSnapshot(sideRoot: string, canons: readonly CanonDeclarations[], handed?: Readonly<Record<string, GraphSnapshot>>): Record<string, GraphFinding[]> | null;
150
159
  /**
151
160
  * Judges one side: ESLint (and, for fe, stylelint) over what the records cannot vouch for, the records of the rest, and the new records.
152
161
  * Never throws for a tool failure: those come back in `errors`, and a side with an error stores nothing.
@@ -164,15 +164,20 @@ function graphHost(ts, sideRoot, hashOf) {
164
164
  }
165
165
  /** A record's `data` object, or an empty one. */
166
166
  const dataOf = (record) => (record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data) ? record.data : {});
167
- /** The graph findings of every canon that publishes graph rules, merged by file and sorted. */
168
- function mergedSnapshot(sideRoot, canons) {
167
+ /**
168
+ * The graph findings of every canon that publishes graph rules, merged by file and sorted. A canon whose snapshot the ESLint child handed out (`handed`, by
169
+ * canon name) is not asked to build its graph again; any other canon builds it through its `projectGraph`.
170
+ */
171
+ export function mergedSnapshot(sideRoot, canons, handed) {
169
172
  const merged = {};
170
173
  for (const canon of canons) {
171
174
  if (!canon.graphRules)
172
175
  continue;
173
- if (canon.projectGraph === null)
176
+ const given = handed?.[canon.name];
177
+ if (given === undefined && canon.projectGraph === null)
174
178
  return null;
175
- for (const [file, findings] of Object.entries(canon.projectGraph({ repoRoot: sideRoot }).byFile))
179
+ const { byFile } = given ?? canon.projectGraph({ repoRoot: sideRoot });
180
+ for (const [file, findings] of Object.entries(byFile))
176
181
  merged[posix(file)] = [...(merged[posix(file)] ?? []), ...findings];
177
182
  }
178
183
  for (const list of Object.values(merged))
@@ -331,12 +336,14 @@ export async function lintSide(input) {
331
336
  // ------------------------------------------------------------------ the run
332
337
  const eslintList = [...dirtyEslint].sort();
333
338
  const styleList = [...dirtyStyle].sort();
339
+ let handedGraph;
334
340
  const freshEslint = new Map();
335
341
  const freshStyle = new Map();
336
342
  const rootOf = (file) => path.relative(sideRoot, file).replace(/\\/g, '/');
337
343
  if (eslintList.length > 0) {
338
344
  const run = await input.eslint(whole !== null ? null : eslintList);
339
345
  errors.push(...run.errors);
346
+ handedGraph = run.graph;
340
347
  for (const result of run.results)
341
348
  freshEslint.set(posix(rootOf(result.filePath)), result);
342
349
  }
@@ -408,7 +415,7 @@ export async function lintSide(input) {
408
415
  if (!moved.has(file))
409
416
  records.push({ path: file, step: LINT_STEPS.imports, hash: node.hash, findings: [], data: { globals: node.declaresGlobals, imports: node.imports, ...(node.api === undefined ? {} : { api: node.api }), open: node.open !== false, ...(node.refs === undefined ? {} : { refs: node.refs }) } });
410
417
  }
411
- snapshot ??= mergedSnapshot(sideRoot, input.canons);
418
+ snapshot ??= mergedSnapshot(sideRoot, input.canons, handedGraph);
412
419
  for (const [file, findings] of Object.entries(snapshot ?? {})) {
413
420
  if (scopeSet.has(file) && !moved.has(file) && findings.length > 0)
414
421
  records.push({ path: file, step: LINT_STEPS.graph, hash: planHash(file), findings: graphRecordFindings(side, file, findings) });
@@ -0,0 +1,48 @@
1
+ /** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
2
+ type Ts = typeof import('typescript');
3
+ /** One move of the report: a directive taken from `line` to the top of its module. */
4
+ export interface DirectiveFirstChange {
5
+ file: string;
6
+ line: number;
7
+ column: number;
8
+ code: string;
9
+ from: string;
10
+ to: string;
11
+ }
12
+ /** One case left for a person. */
13
+ export interface DirectiveFirstFinding {
14
+ file: string;
15
+ line: number;
16
+ column: number;
17
+ code: string;
18
+ reason: string;
19
+ }
20
+ /** The report of a migration (`starci app codemod directive-first`). */
21
+ export interface DirectiveFirstReport {
22
+ schema: string;
23
+ root: string;
24
+ write: boolean;
25
+ filesScanned: number;
26
+ filesChanged: string[];
27
+ changes: DirectiveFirstChange[];
28
+ findings: DirectiveFirstFinding[];
29
+ }
30
+ /** Move every `"use client"` / `"use server"` that is not in the prologue of the module to the top of it, as its first statement, with one blank line after. */
31
+ export declare function transformDirectiveFirst({ ts, text, file }: {
32
+ ts: Ts;
33
+ text: string;
34
+ file?: string;
35
+ }): {
36
+ text: string;
37
+ changes: DirectiveFirstChange[];
38
+ findings: DirectiveFirstFinding[];
39
+ };
40
+ /** Preview or apply the moves in one app (`be/`, `fe/`, or `src/` of a single-side app); a conflicting module is kept in the returned report. */
41
+ export declare function migrateDirectiveFirst({ root, write, ts }: {
42
+ root: string;
43
+ write?: boolean;
44
+ ts?: Ts | null;
45
+ }): DirectiveFirstReport;
46
+ /** Render the moves and what is left for a person as Markdown; JSON consumers read the same report object. */
47
+ export declare function directiveFirstMarkdown(report: DirectiveFirstReport): string;
48
+ export {};
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
5
+ import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
6
+ import { filesUnder, relativePosix, sideFolders } from './source-tree.mjs';
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ /** The directives Next.js reads from the prologue of a module (R369, FE_DIRECTIVE_FIRST). */
9
+ const DIRECTIVES = new Set(['use client', 'use server']);
10
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
11
+ const DECLARATION = /\.d\.[cm]?ts$/i;
12
+ /** The string literal a statement is made of, through the parentheses Prettier wraps it in, or null. */
13
+ function stringStatement(ts, statement) {
14
+ if (!ts.isExpressionStatement(statement))
15
+ return null;
16
+ let expression = statement.expression;
17
+ while (ts.isParenthesizedExpression(expression))
18
+ expression = expression.expression;
19
+ return ts.isStringLiteral(expression) ? expression : null;
20
+ }
21
+ /** The span to delete for `statement`: its own line when nothing else is on it (with a `;` Prettier put before a parenthesized statement), else the statement alone. */
22
+ function removalSpan(text, statement, source) {
23
+ let start = statement.getStart(source);
24
+ let end = statement.end;
25
+ let from = start;
26
+ while (from > 0 && (text[from - 1] === ' ' || text[from - 1] === '\t'))
27
+ from -= 1;
28
+ if (text[from - 1] === ';' && (from - 1 === 0 || text[from - 2] === '\n'))
29
+ from -= 1;
30
+ const startsLine = from === 0 || text[from - 1] === '\n';
31
+ let to = end;
32
+ while (to < text.length && (text[to] === ' ' || text[to] === '\t'))
33
+ to += 1;
34
+ const endsLine = to >= text.length || text[to] === '\n' || text[to] === '\r';
35
+ if (startsLine && endsLine) {
36
+ start = from;
37
+ end = text[to] === '\r' ? to + 2 : to + 1;
38
+ }
39
+ return { start, end: Math.min(end, text.length) };
40
+ }
41
+ /** Move every `"use client"` / `"use server"` that is not in the prologue of the module to the top of it, as its first statement, with one blank line after. */
42
+ export function transformDirectiveFirst({ ts, text, file = 'module.tsx' }) {
43
+ const none = { text, changes: [], findings: [] };
44
+ if (!text.includes('use client') && !text.includes('use server'))
45
+ return none;
46
+ const kind = /\.tsx$/i.test(file) ? ts.ScriptKind.TSX : /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : /\.[cm]?js$/i.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
47
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
48
+ const first = source.statements[0];
49
+ if (first === undefined)
50
+ return none;
51
+ // The prologue is the run of plain string statements at the top; a parenthesized one is an expression, not a directive.
52
+ let prologueEnd = 0;
53
+ const kept = new Set();
54
+ while (prologueEnd < source.statements.length) {
55
+ const statement = source.statements[prologueEnd];
56
+ if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
57
+ break;
58
+ kept.add(statement.expression.text);
59
+ prologueEnd += 1;
60
+ }
61
+ const misplaced = source.statements.slice(prologueEnd).flatMap((statement, offset) => {
62
+ const literal = stringStatement(ts, statement);
63
+ return literal !== null && DIRECTIVES.has(literal.text) ? [{ statement, literal, before: prologueEnd + offset }] : [];
64
+ });
65
+ if (!misplaced.length)
66
+ return none;
67
+ const changes = [];
68
+ const findings = [];
69
+ const opposite = (directive) => (directive === 'use client' ? 'use server' : 'use client');
70
+ const wanted = new Set(misplaced.map(entry => entry.literal.text));
71
+ const edits = [];
72
+ const toTop = [];
73
+ const placed = new Set(kept);
74
+ for (const { statement, literal, before } of misplaced) {
75
+ const place = sourceLocation(source, statement);
76
+ const directive = literal.text;
77
+ if (kept.has(opposite(directive)) || wanted.has(opposite(directive))) {
78
+ findings.push({ file, ...place, code: 'CONFLICTING_DIRECTIVES', reason: `"use client" and "use server" cannot share a module: split it, then keep the one directive that stays at the top` });
79
+ continue;
80
+ }
81
+ edits.push({ ...removalSpan(text, statement, source), text: '' });
82
+ if (!placed.has(directive)) {
83
+ toTop.push(literal.getText(source) + (statement.getText(source).endsWith(';') ? ';' : ''));
84
+ placed.add(directive);
85
+ }
86
+ const mangled = ts.isExpressionStatement(statement) && ts.isParenthesizedExpression(statement.expression);
87
+ changes.push({ file, ...place, code: mangled ? 'DIRECTIVE_PARENTHESIZED' : 'DIRECTIVE_BELOW_STATEMENT', from: `${mangled ? 'parenthesized, ' : ''}after ${before} statement${before === 1 ? '' : 's'}`, to: 'first statement' });
88
+ }
89
+ if (toTop.length)
90
+ edits.push({ start: first.getStart(source), end: first.getStart(source), text: `${toTop.join('\n')}\n\n` });
91
+ let output = text;
92
+ for (const edit of edits.sort((a, b) => b.start - a.start || b.end - a.end))
93
+ output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
94
+ return { text: output, changes, findings };
95
+ }
96
+ /** Preview or apply the moves in one app (`be/`, `fe/`, or `src/` of a single-side app); a conflicting module is kept in the returned report. */
97
+ export function migrateDirectiveFirst({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
98
+ if (!ts)
99
+ throw new Error('directive-first needs TypeScript installed in the app or @starci/hfs development checkout');
100
+ const folders = sideFolders(root);
101
+ if (!folders.length)
102
+ throw new Error('directive-first runs at an app root with a fe/ directory');
103
+ const report = { schema: 'starci/directive-first-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
104
+ for (const target of folders.flatMap(folder => filesUnder(folder, SOURCE)).filter(target => !DECLARATION.test(target))) {
105
+ const file = relativePosix(root, target);
106
+ const original = fs.readFileSync(target, 'utf8');
107
+ const result = transformDirectiveFirst({ ts, text: original, file });
108
+ report.filesScanned += 1;
109
+ report.changes.push(...result.changes);
110
+ report.findings.push(...result.findings);
111
+ if (result.text !== original) {
112
+ report.filesChanged.push(file);
113
+ if (write)
114
+ fs.writeFileSync(target, result.text);
115
+ }
116
+ }
117
+ return report;
118
+ }
119
+ const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
120
+ /** Render the moves and what is left for a person as Markdown; JSON consumers read the same report object. */
121
+ export function directiveFirstMarkdown(report) {
122
+ const out = ['# directive-first', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} directives moved, ${report.findings.length} left for a person.`, ''];
123
+ if (report.findings.length) {
124
+ out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
125
+ for (const finding of report.findings)
126
+ out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
127
+ out.push('');
128
+ }
129
+ if (report.changes.length) {
130
+ out.push('## Moves', '', '| location | code | from | to |', '|---|---|---|---|');
131
+ for (const item of report.changes)
132
+ out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | ${cell(item.from)} | ${cell(item.to)} |`);
133
+ out.push('');
134
+ }
135
+ return `${out.join('\n')}\n`;
136
+ }
@@ -0,0 +1,14 @@
1
+ import type { ImportNeed } from './explicit-surface-render.mjs';
2
+ import type * as TsTypes from 'typescript';
3
+ type Ts = typeof import('typescript');
4
+ /** One text edit of the source: the span `start`..`end` becomes `text`. */
5
+ export interface Edit {
6
+ start: number;
7
+ end: number;
8
+ text: string;
9
+ }
10
+ /** The edits that give a file the imports `needs` names: a specifier added to a named import of the same module, else one `import type` line per module. */
11
+ export declare function importEdits(ts: Ts, source: TsTypes.SourceFile, text: string, needs: readonly ImportNeed[]): Edit[];
12
+ /** `text` with `edits` applied, last position first so every offset stays true; edits at one position keep their order. */
13
+ export declare function applyEdits(text: string, edits: readonly Edit[]): string;
14
+ export {};
@@ -0,0 +1,57 @@
1
+ /** The edits that give a file the imports `needs` names: a specifier added to a named import of the same module, else one `import type` line per module. */
2
+ export function importEdits(ts, source, text, needs) {
3
+ if (!needs.length)
4
+ return [];
5
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
6
+ const imports = source.statements.filter(ts.isImportDeclaration);
7
+ const first = imports[0];
8
+ const quote = first !== undefined && ts.isStringLiteral(first.moduleSpecifier) ? first.moduleSpecifier.getText(source).slice(0, 1) : '"';
9
+ const semicolon = first !== undefined && first.getText(source).endsWith(';') ? ';' : '';
10
+ const edits = [];
11
+ const lines = [];
12
+ const bySpecifier = new Map();
13
+ for (const need of needs)
14
+ bySpecifier.set(need.specifier, [...(bySpecifier.get(need.specifier) ?? []), need.name]);
15
+ for (const [specifier, names] of bySpecifier) {
16
+ const sorted = [...new Set(names)].sort();
17
+ const home = imports.find(declaration => ts.isStringLiteral(declaration.moduleSpecifier) && declaration.moduleSpecifier.text === specifier
18
+ && declaration.importClause?.namedBindings !== undefined && ts.isNamedImports(declaration.importClause.namedBindings) && declaration.importClause.namedBindings.elements.length > 0);
19
+ const clause = home?.importClause;
20
+ const bindings = clause?.namedBindings;
21
+ if (clause !== undefined && bindings !== undefined && ts.isNamedImports(bindings)) {
22
+ const last = bindings.elements[bindings.elements.length - 1];
23
+ edits.push({ start: last.end, end: last.end, text: sorted.map(name => `, ${clause.isTypeOnly ? '' : 'type '}${name}`).join('') });
24
+ }
25
+ else
26
+ lines.push(`import type { ${sorted.join(', ')} } from ${quote}${specifier}${quote}${semicolon}`);
27
+ }
28
+ if (lines.length)
29
+ edits.push(newImports(ts, source, text, lines, eol));
30
+ return edits;
31
+ }
32
+ /** The edit that adds whole import lines: after the last import, else after the directive prologue, else above the first statement. */
33
+ function newImports(ts, source, text, lines, eol) {
34
+ const block = lines.join(eol);
35
+ const last = source.statements.filter(ts.isImportDeclaration).at(-1);
36
+ if (last !== undefined)
37
+ return { start: last.end, end: last.end, text: `${eol}${block}` };
38
+ let prologue;
39
+ for (const statement of source.statements) {
40
+ if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
41
+ break;
42
+ prologue = statement;
43
+ }
44
+ if (prologue !== undefined)
45
+ return { start: prologue.end, end: prologue.end, text: `${eol}${eol}${block}` };
46
+ const first = source.statements[0];
47
+ const at = first === undefined ? text.length : first.getStart(source);
48
+ return { start: at, end: at, text: `${block}${eol}${eol}` };
49
+ }
50
+ /** `text` with `edits` applied, last position first so every offset stays true; edits at one position keep their order. */
51
+ export function applyEdits(text, edits) {
52
+ const ordered = edits.map((edit, at) => ({ edit, at })).sort((a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.at - a.at);
53
+ let output = text;
54
+ for (const { edit } of ordered)
55
+ output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
56
+ return output;
57
+ }
@@ -0,0 +1,30 @@
1
+ import type * as TsTypes from 'typescript';
2
+ /** The TypeScript compiler API of the app's own install, typed by the version this package develops against. */
3
+ type Ts = typeof import('typescript');
4
+ /** What naming a type reads of the app: its compiler, program and one source file. */
5
+ export interface NamingContext {
6
+ ts: Ts;
7
+ program: TsTypes.Program;
8
+ checker: TsTypes.TypeChecker;
9
+ options: TsTypes.CompilerOptions;
10
+ source: TsTypes.SourceFile;
11
+ /** The absolute, forward-slash name of the file being annotated. */
12
+ file: string;
13
+ text: string;
14
+ }
15
+ /** The source file of the program a module path (absolute, without its extension) names, or undefined. */
16
+ export declare function moduleFile(c: NamingContext, modulePath: string): TsTypes.SourceFile | undefined;
17
+ /** The symbol a module exports under `name`, with aliases resolved, or undefined. */
18
+ export declare function exportedSymbol(c: NamingContext, file: TsTypes.SourceFile, name: string): TsTypes.Symbol | undefined;
19
+ /** The declaration `symbol` stands for: an import resolved to what it imports, a local export to its export symbol, so two names of one declaration compare equal. */
20
+ export declare function declarationOf(c: NamingContext, symbol: TsTypes.Symbol): TsTypes.Symbol;
21
+ /** The names that can be written at `enclosing` without an import, as symbols; what a function body declares is not visible from its signature. */
22
+ export declare function namesInScope(c: NamingContext, enclosing: TsTypes.Node): Map<string, TsTypes.Symbol>;
23
+ /**
24
+ * `node` (the type node the checker wrote for `type`) with every anonymous object type that an exported type of the app stands for replaced by a
25
+ * reference to that type. It follows type arguments (`Promise<{...}>`), arrays and unions; any other anonymous part stays for the caller to refuse.
26
+ */
27
+ export declare function nameAnonymousParts(c: NamingContext, node: TsTypes.TypeNode, type: TsTypes.Type, enclosing: TsTypes.Node): TsTypes.TypeNode;
28
+ /** The directory-relative form of `target` from the folder of `from`, without extension, the way a relative import is written. */
29
+ export declare function relativeSpecifier(from: string, target: string): string;
30
+ export {};
@@ -0,0 +1,135 @@
1
+ import path from 'node:path';
2
+ const EXTENSIONS = ['', '.ts', '.tsx', '.d.ts', '.mts', '.d.mts', '.cts', '.d.cts', '/index.ts', '/index.tsx', '/index.d.ts'];
3
+ const posix = (file) => file.replaceAll('\\', '/');
4
+ /** The source file of the program a module path (absolute, without its extension) names, or undefined. */
5
+ export function moduleFile(c, modulePath) {
6
+ for (const extension of EXTENSIONS) {
7
+ const found = c.program.getSourceFile(posix(modulePath) + extension);
8
+ if (found !== undefined)
9
+ return found;
10
+ }
11
+ return undefined;
12
+ }
13
+ /** The symbol a module exports under `name`, with aliases resolved, or undefined. */
14
+ export function exportedSymbol(c, file, name) {
15
+ const module = c.checker.getSymbolAtLocation(file);
16
+ if (module === undefined)
17
+ return undefined;
18
+ const found = c.checker.getExportsOfModule(module).find(symbol => symbol.name === name);
19
+ if (found === undefined)
20
+ return undefined;
21
+ return (found.flags & c.ts.SymbolFlags.Alias) === 0 ? found : c.checker.getAliasedSymbol(found);
22
+ }
23
+ /** The declaration `symbol` stands for: an import resolved to what it imports, a local export to its export symbol, so two names of one declaration compare equal. */
24
+ export function declarationOf(c, symbol) {
25
+ const target = (symbol.flags & c.ts.SymbolFlags.Alias) === 0 ? symbol : c.checker.getAliasedSymbol(symbol);
26
+ return c.checker.getExportSymbolOfSymbol(target);
27
+ }
28
+ /** The key that groups candidate types before the expensive assignability test: the sorted property names. */
29
+ const shapeKey = (names) => [...names].sort().join(',');
30
+ const indexes = new WeakMap();
31
+ /** The exported, non-generic object types of the app's own files (never installed packages or declaration files), by their property names. */
32
+ function namedTypes(c) {
33
+ const known = indexes.get(c.program);
34
+ if (known !== undefined)
35
+ return known;
36
+ const found = new Map();
37
+ for (const file of c.program.getSourceFiles()) {
38
+ if (file.isDeclarationFile || c.program.isSourceFileFromExternalLibrary(file) || file.fileName.includes('/node_modules/'))
39
+ continue;
40
+ const module = c.checker.getSymbolAtLocation(file);
41
+ if (module === undefined)
42
+ continue;
43
+ for (const symbol of c.checker.getExportsOfModule(module)) {
44
+ const target = (symbol.flags & c.ts.SymbolFlags.Alias) === 0 ? symbol : c.checker.getAliasedSymbol(symbol);
45
+ if ((target.flags & (c.ts.SymbolFlags.TypeAlias | c.ts.SymbolFlags.Interface)) === 0)
46
+ continue;
47
+ const type = c.checker.getDeclaredTypeOfSymbol(target);
48
+ const generic = type.typeParameters?.length ?? type.aliasTypeArguments?.length ?? 0;
49
+ if (generic > 0 || (type.flags & c.ts.TypeFlags.Object) === 0)
50
+ continue;
51
+ const key = shapeKey(c.checker.getPropertiesOfType(type).map(property => property.name));
52
+ if (key === '')
53
+ continue;
54
+ found.set(key, [...(found.get(key) ?? []), { name: symbol.name, type, file: file.fileName }]);
55
+ }
56
+ }
57
+ indexes.set(c.program, found);
58
+ return found;
59
+ }
60
+ /** The names that can be written at `enclosing` without an import, as symbols; what a function body declares is not visible from its signature. */
61
+ export function namesInScope(c, enclosing) {
62
+ const flags = c.ts.SymbolFlags.Type | c.ts.SymbolFlags.Namespace | c.ts.SymbolFlags.Alias | c.ts.SymbolFlags.Value;
63
+ const body = enclosing.body;
64
+ const inBody = (symbol) => body !== undefined && symbol.declarations?.length !== 0 && symbol.declarations?.every(declaration => declaration.pos >= body.pos && declaration.end <= body.end) === true;
65
+ return new Map(c.checker.getSymbolsInScope(enclosing, flags).filter(symbol => !inBody(symbol)).map(symbol => [symbol.name, symbol]));
66
+ }
67
+ /**
68
+ * The one exported type of the app that is interchangeable with the inferred object type `type` (each assignable to the other and the same property names),
69
+ * preferring a name the file already has in scope; undefined when none or more than one fits, so a guess never stands for the inference.
70
+ */
71
+ function matchingName(c, type, enclosing) {
72
+ if ((type.flags & c.ts.TypeFlags.Object) === 0)
73
+ return undefined;
74
+ const key = shapeKey(c.checker.getPropertiesOfType(type).map(property => property.name));
75
+ const fits = (namedTypes(c).get(key) ?? []).filter(named => c.checker.isTypeAssignableTo(type, named.type) && c.checker.isTypeAssignableTo(named.type, type));
76
+ if (fits.length <= 1)
77
+ return fits[0];
78
+ const scope = namesInScope(c, enclosing);
79
+ const local = fits.filter(named => scope.has(named.name));
80
+ return local.length === 1 ? local[0] : undefined;
81
+ }
82
+ /** The type argument nodes of `node` paired with the type arguments the checker holds for `type`. */
83
+ function argumentsOf(c, type) {
84
+ if ((type.flags & c.ts.TypeFlags.Object) !== 0 && (type.objectFlags & c.ts.ObjectFlags.Reference) !== 0)
85
+ return c.checker.getTypeArguments(type);
86
+ return type.aliasTypeArguments ?? [];
87
+ }
88
+ /**
89
+ * `node` (the type node the checker wrote for `type`) with every anonymous object type that an exported type of the app stands for replaced by a
90
+ * reference to that type. It follows type arguments (`Promise<{...}>`), arrays and unions; any other anonymous part stays for the caller to refuse.
91
+ */
92
+ export function nameAnonymousParts(c, node, type, enclosing) {
93
+ const { ts } = c;
94
+ const reference = (named) => c.checker.typeToTypeNode(named.type, enclosing, ts.NodeBuilderFlags.NoTruncation);
95
+ if (ts.isTypeLiteralNode(node) && node.members.length > 0) {
96
+ const named = matchingName(c, type, enclosing);
97
+ return (named === undefined ? undefined : reference(named)) ?? node;
98
+ }
99
+ if (ts.isArrayTypeNode(node) || ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node)) {
100
+ const given = ts.isArrayTypeNode(node) ? [node.elementType] : node.typeArguments ?? [];
101
+ const held = argumentsOf(c, type);
102
+ if (!given.length || held.length < given.length)
103
+ return node;
104
+ const named = given.map((child, at) => nameAnonymousParts(c, child, held[at], enclosing));
105
+ if (named.every((child, at) => child === given[at]))
106
+ return node;
107
+ const { factory } = ts;
108
+ if (ts.isArrayTypeNode(node))
109
+ return factory.updateArrayTypeNode(node, named[0]);
110
+ if (ts.isImportTypeNode(node))
111
+ return factory.updateImportTypeNode(node, node.argument, node.attributes, node.qualifier, named, node.isTypeOf);
112
+ return factory.updateTypeReferenceNode(node, node.typeName, factory.createNodeArray(named));
113
+ }
114
+ if (ts.isUnionTypeNode(node) && type.isUnion()) {
115
+ const members = type.types.filter(member => (member.flags & ts.TypeFlags.Object) !== 0);
116
+ const used = new Set();
117
+ const named = node.types.map(child => {
118
+ if (!ts.isTypeLiteralNode(child))
119
+ return child;
120
+ const key = shapeKey(child.members.map(member => (member.name === undefined ? '' : ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) ? member.name.text : '')));
121
+ const match = members.find(member => !used.has(member) && shapeKey(c.checker.getPropertiesOfType(member).map(property => property.name)) === key);
122
+ if (match === undefined)
123
+ return child;
124
+ used.add(match);
125
+ return nameAnonymousParts(c, child, match, enclosing);
126
+ });
127
+ return named.every((child, at) => child === node.types[at]) ? node : ts.factory.updateUnionTypeNode(node, ts.factory.createNodeArray(named));
128
+ }
129
+ return node;
130
+ }
131
+ /** The directory-relative form of `target` from the folder of `from`, without extension, the way a relative import is written. */
132
+ export function relativeSpecifier(from, target) {
133
+ const relative = posix(path.relative(path.dirname(from), target)).replace(/\.(?:d\.)?[cm]?tsx?$/, '');
134
+ return relative.startsWith('.') ? relative : `./${relative}`;
135
+ }
@@ -0,0 +1,29 @@
1
+ import { type NamingContext } from './explicit-surface-names.mjs';
2
+ import type * as TsTypes from 'typescript';
3
+ /** An import the written type needs: `name` exported by the module `specifier`. */
4
+ export interface ImportNeed {
5
+ name: string;
6
+ specifier: string;
7
+ }
8
+ /** A type written as source text, with the imports it needs. */
9
+ export interface Rendered {
10
+ ok: true;
11
+ text: string;
12
+ needs: ImportNeed[];
13
+ }
14
+ /** Why a type is not written: a code of the report, the reason, and what a person does about it. */
15
+ export interface Refused {
16
+ ok: false;
17
+ code: string;
18
+ reason: string;
19
+ suggestion: string;
20
+ }
21
+ /** The longest type the codemod writes on one line; a longer one is a type a person names. */
22
+ export declare const MAX_TYPE_LENGTH = 100;
23
+ /**
24
+ * The type Next.js names for a route export (`generateMetadata`: `Promise<Metadata>`, `metadata`: `Metadata`, and the viewport pair), when `next` is installed and the inferred
25
+ * type is assignable to it; undefined for any other export, so the inferred type is written instead.
26
+ */
27
+ export declare function conventionType(c: NamingContext, name: string, inferred: TsTypes.Type, enclosing: TsTypes.Node, kind: 'function' | 'variable'): Rendered | undefined;
28
+ /** Writes `type`, as it is seen from `enclosing`, as source text: named types by name, the imports they need, or the reason it is left to a person. */
29
+ export declare function renderType(c: NamingContext, type: TsTypes.Type, enclosing: TsTypes.Node, maxLength?: number): Rendered | Refused;