@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,50 @@
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 annotation written: the type of an export that was inferred. */
4
+ export interface ExplicitSurfaceChange {
5
+ file: string;
6
+ line: number;
7
+ column: number;
8
+ code: string;
9
+ name: string;
10
+ from: string;
11
+ to: string;
12
+ }
13
+ /** One export left for a person, with why and what to do. */
14
+ export interface ExplicitSurfaceFinding {
15
+ file: string;
16
+ line: number;
17
+ column: number;
18
+ code: string;
19
+ name: string;
20
+ annotate: string;
21
+ reason: string;
22
+ suggestion: string;
23
+ }
24
+ /** The report of a migration (`starci app codemod explicit-surface`). */
25
+ export interface ExplicitSurfaceReport {
26
+ schema: string;
27
+ root: string;
28
+ write: boolean;
29
+ maxTypeLength: number;
30
+ filesScanned: number;
31
+ filesOpen: number;
32
+ filesChanged: string[];
33
+ filesStillOpen: number;
34
+ formatted: boolean;
35
+ changes: ExplicitSurfaceChange[];
36
+ findings: ExplicitSurfaceFinding[];
37
+ }
38
+ /**
39
+ * Preview or apply the explicit type annotations of one app: for every TypeScript source of each side that the declaration classifier counts as open
40
+ * (R368, HFS_EXPLICIT_EXPORT_SURFACE), the types the app's own checker infers are written where they are short, named and sound. Every other export is kept in the report.
41
+ */
42
+ export declare function migrateExplicitSurface({ root, write, ts: given, maxTypeLength }: {
43
+ root: string;
44
+ write?: boolean;
45
+ ts?: Ts | null;
46
+ maxTypeLength?: number;
47
+ }): Promise<ExplicitSurfaceReport>;
48
+ /** Render the annotations and what is left for a person as Markdown; JSON consumers read the same report object. */
49
+ export declare function explicitSurfaceMarkdown(report: ExplicitSurfaceReport): string;
50
+ export {};
@@ -0,0 +1,374 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { declarationSurface, isEmittableSource } from '../runtime/scripts/lib/declaration-surface.mjs';
5
+ import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
6
+ import { loadPrettier } from '../sync/format.mjs';
7
+ import { applyEdits, importEdits } from './explicit-surface-edit.mjs';
8
+ import { conventionType, MAX_TYPE_LENGTH, renderType } from './explicit-surface-render.mjs';
9
+ import { GENERATED, relativePosix, sideFolders } from './source-tree.mjs';
10
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const posix = (file) => file.replaceAll('\\', '/');
12
+ /** A file a generator wrote: it is regenerated, never edited. */
13
+ const GENERATED_HEADER = /@generated|do not edit|auto-generated|autogenerated/i;
14
+ /** What to do about an export the classifier reports and the codemod does not edit, by what the classifier says to annotate. */
15
+ const SUGGESTIONS = {
16
+ 'destructured export': 'export each name as its own variable with a type: `export const name: Type = source.name`',
17
+ 'accessor type': 'write the accessor type: `get name(): Type`',
18
+ 'property type': 'write the property type: `name: Type = ...`',
19
+ 'exported value': 'declare a named type, export it and annotate the export: `export default value satisfies Type` or `export const name: Type = ...`',
20
+ };
21
+ /** The innermost node of `source` that contains `start`. */
22
+ function innermostAt(ts, source, start) {
23
+ let found = source;
24
+ const descend = (node) => {
25
+ if (start < node.getStart(source) || start >= node.end)
26
+ return;
27
+ found = node;
28
+ ts.forEachChild(node, descend);
29
+ };
30
+ ts.forEachChild(source, descend);
31
+ return found;
32
+ }
33
+ /** The declaration a diagnostic of the isolated declaration emit sits in, or undefined when the codemod has no annotation for it. */
34
+ function locate(ts, source, problem) {
35
+ for (let node = innermostAt(ts, source, problem.start); node !== undefined && node !== source; node = node.parent) {
36
+ const named = (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name !== undefined && node.name.getStart(source) === problem.start;
37
+ if ((problem.code === 9007 || problem.code === 9008) && (named || ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.getStart(source) === problem.start))) {
38
+ return { kind: 'return', node: node };
39
+ }
40
+ if (problem.code === 9010 && ts.isVariableDeclaration(node) && node.name.getStart(source) === problem.start)
41
+ return { kind: 'variable', node };
42
+ if ((problem.code === 9011 || problem.code === 9025) && ts.isParameter(node))
43
+ return { kind: 'parameter', node };
44
+ if (problem.code === 9012 && ts.isPropertyDeclaration(node) && node.name.getStart(source) === problem.start)
45
+ return { kind: 'property', node };
46
+ }
47
+ return problem.annotate === 'exported value' ? declarationOfExpression(ts, source, problem.start) : undefined;
48
+ }
49
+ /** The function, variable, parameter or property whose type an expression the emit cannot read (an array, an object with a shorthand, a call) stands for. */
50
+ function declarationOfExpression(ts, source, start) {
51
+ for (let node = innermostAt(ts, source, start); node !== source; node = node.parent) {
52
+ const { parent } = node;
53
+ if (ts.isReturnStatement(node)) {
54
+ for (let owner = node; owner !== source; owner = owner.parent) {
55
+ if (ts.isFunctionDeclaration(owner) || ts.isMethodDeclaration(owner) || ts.isArrowFunction(owner) || ts.isFunctionExpression(owner))
56
+ return { kind: 'return', node: owner };
57
+ }
58
+ return undefined;
59
+ }
60
+ if (ts.isArrowFunction(parent) && parent.body === node)
61
+ return { kind: 'return', node: parent };
62
+ if (ts.isVariableDeclaration(parent) && parent.initializer === node)
63
+ return { kind: 'variable', node: parent };
64
+ if (ts.isParameter(parent) && parent.initializer === node)
65
+ return { kind: 'parameter', node: parent };
66
+ if (ts.isPropertyDeclaration(parent) && parent.initializer === node)
67
+ return { kind: 'property', node: parent };
68
+ }
69
+ return undefined;
70
+ }
71
+ /** The edits that write `typeText` on `target`. */
72
+ function annotationEdits(ts, source, target, typeText) {
73
+ const insert = (at, text) => ({ start: at, end: at, text });
74
+ if (target.kind === 'variable')
75
+ return [insert(target.node.exclamationToken?.end ?? target.node.name.end, `: ${typeText}`)];
76
+ if (target.kind === 'parameter')
77
+ return [insert(target.node.questionToken?.end ?? target.node.name.end, `: ${typeText}`)];
78
+ if (target.kind === 'property')
79
+ return [insert((target.node.questionToken ?? target.node.exclamationToken)?.end ?? target.node.name.end, `: ${typeText}`)];
80
+ const { node } = target;
81
+ const children = node.getChildren(source);
82
+ const open = children.find(child => child.kind === ts.SyntaxKind.OpenParenToken);
83
+ if (open === undefined) {
84
+ // `x => x` has no parentheses to carry a return type: `(x): T => x`.
85
+ const only = node.parameters[0];
86
+ return [insert(only.getStart(source), '('), insert(only.end, `): ${typeText}`)];
87
+ }
88
+ const close = children.find(child => child.kind === ts.SyntaxKind.CloseParenToken && child.pos >= node.parameters.end);
89
+ return [insert(close.end, `: ${typeText}`)];
90
+ }
91
+ /** The change kind of a target, as the report names it. */
92
+ const CODE = { return: 'RETURN_TYPE', variable: 'VARIABLE_TYPE', parameter: 'PARAMETER_TYPE', property: 'PROPERTY_TYPE' };
93
+ /** The type to write for `target`, from the checker, or the reason it is not written. */
94
+ function typeFor(c, target, name, maxLength) {
95
+ const { checker, ts } = c;
96
+ if (target.kind === 'return') {
97
+ const signature = checker.getSignatureFromDeclaration(target.node);
98
+ if (signature === undefined || target.node.body === undefined)
99
+ return { ok: false, code: 'NO_SIGNATURE', reason: 'the function has no body to infer from', suggestion: 'write the return type by hand' };
100
+ if (checker.getTypePredicateOfSignature(signature) !== undefined)
101
+ return { ok: false, code: 'TYPE_PREDICATE', reason: 'the return type is an inferred type predicate, which `boolean` would erase', suggestion: 'write the predicate: `value is Type`' };
102
+ const inferred = checker.getReturnTypeOfSignature(signature);
103
+ return conventionType(c, name, inferred, target.node, 'function') ?? renderType(c, inferred, target.node, maxLength);
104
+ }
105
+ const node = target.kind === 'parameter' ? target.node : target.node.name;
106
+ if (target.kind === 'parameter' && !ts.isIdentifier(target.node.name))
107
+ return { ok: false, code: 'DESTRUCTURED_PARAMETER', reason: 'the parameter is a destructuring pattern', suggestion: 'write the parameter type: `({ a, b }: Props)`' };
108
+ const inferred = checker.getTypeAtLocation(node);
109
+ return (target.kind === 'variable' ? conventionType(c, name, inferred, node, 'variable') : undefined) ?? renderType(c, inferred, node, maxLength);
110
+ }
111
+ /** Plans the annotations of one open file; every problem it does not annotate becomes a finding. */
112
+ function planFile(c, file, problems, maxLength) {
113
+ const plan = { edits: [], needs: [], changes: [], findings: [] };
114
+ const seen = new Set();
115
+ for (const problem of problems) {
116
+ const place = c.source.getLineAndCharacterOfPosition(problem.start);
117
+ const where = { file, line: place.line + 1, column: place.character + 1, name: problem.name, annotate: problem.annotate };
118
+ const target = locate(c.ts, c.source, problem);
119
+ if (target === undefined) {
120
+ plan.findings.push({ ...where, code: problem.annotate === 'destructured export' ? 'DESTRUCTURED_EXPORT' : 'NOT_ANNOTATED', reason: problem.reason, suggestion: SUGGESTIONS[problem.annotate] ?? 'annotate it by hand' });
121
+ continue;
122
+ }
123
+ const key = `${target.kind}:${target.node.pos}`;
124
+ if (seen.has(key))
125
+ continue;
126
+ seen.add(key);
127
+ const rendered = typeFor(c, target, problem.name, maxLength);
128
+ if (!rendered.ok) {
129
+ plan.findings.push({ ...where, code: rendered.code, reason: rendered.reason, suggestion: rendered.suggestion });
130
+ continue;
131
+ }
132
+ plan.edits.push(...annotationEdits(c.ts, c.source, target, rendered.text));
133
+ plan.needs.push(...rendered.needs);
134
+ plan.changes.push({ ...where, code: CODE[target.kind], from: 'inferred', to: rendered.text });
135
+ }
136
+ return plan;
137
+ }
138
+ /** The diagnostics of a file as a multiset of `code: message`, with positions left out so an edit above them does not count. */
139
+ function diagnosticsOf(ts, program, file) {
140
+ const counts = new Map();
141
+ for (const diagnostic of [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]) {
142
+ const key = `${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`;
143
+ counts.set(key, (counts.get(key) ?? 0) + 1);
144
+ }
145
+ return counts;
146
+ }
147
+ /** The first diagnostic the new text has that the old text did not, or undefined. */
148
+ function addedDiagnostic(before, after) {
149
+ for (const [key, count] of after)
150
+ if (count > (before.get(key) ?? 0))
151
+ return key;
152
+ return undefined;
153
+ }
154
+ /** The tsconfig that owns `file`: the nearest `tsconfig.json` from its folder up to `top`, else undefined. */
155
+ function owningConfig(file, top) {
156
+ for (let folder = path.dirname(file); folder.length >= top.length; folder = path.dirname(folder)) {
157
+ const candidate = path.join(folder, 'tsconfig.json');
158
+ if (fs.existsSync(candidate))
159
+ return candidate;
160
+ if (folder === path.dirname(folder))
161
+ break;
162
+ }
163
+ return undefined;
164
+ }
165
+ /** The options and root files of the tsconfig at `config`, or the inferred ones when there is none. */
166
+ function groupOf(ts, config, files) {
167
+ const parsed = config === undefined ? undefined : ts.getParsedCommandLineOfConfigFile(config, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => undefined });
168
+ const options = parsed?.options ?? { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, noEmit: true };
169
+ const known = new Set((parsed?.fileNames ?? []).map(posix));
170
+ return { options: { ...options, noEmit: true }, rootNames: [...known, ...files.map(posix).filter(file => !known.has(file))], files: [...files] };
171
+ }
172
+ /** The source file the program holds for `file`, found by its forward-slash name, ignoring the case of the drive letter. */
173
+ function programFile(program, file) {
174
+ const normal = posix(file);
175
+ return program.getSourceFile(normal) ?? program.getSourceFile(normal.replace(/^[a-z]:/, drive => drive.toUpperCase())) ?? program.getSourceFile(normal.replace(/^[A-Z]:/, drive => drive.toLowerCase()));
176
+ }
177
+ /** A program that reads `texts` in place of the files on disk, reusing the old program's unchanged files. */
178
+ function programWith(ts, group, texts, old) {
179
+ const host = ts.createCompilerHost(group.options, true);
180
+ const original = host.getSourceFile.bind(host);
181
+ const wanted = new Map([...texts].map(([file, text]) => [posix(file).toLowerCase(), text]));
182
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreate) => {
183
+ const text = wanted.get(posix(fileName).toLowerCase());
184
+ return text === undefined ? original(fileName, languageVersionOrOptions, onError, shouldCreate) : ts.createSourceFile(fileName, text, languageVersionOrOptions, true);
185
+ };
186
+ return ts.createProgram({ rootNames: group.rootNames, options: group.options, host, ...(old === undefined ? {} : { oldProgram: old }) });
187
+ }
188
+ /** The files of a side the codemod reads: those the installed lint canon selects, else every TypeScript source outside generated folders. */
189
+ async function sideSources(root, side, folder) {
190
+ let selects;
191
+ let ignores;
192
+ try {
193
+ const lint = await import('../lint/run.mjs');
194
+ const scope = lint.canonScope(await lint.installedLintConfig({ repoRoot: root, cwd: folder, side }));
195
+ selects = scope.source;
196
+ ignores = (relative) => scope.ignored(relative) || scope.ignored(`${relative}/__starci_scope_probe__`);
197
+ }
198
+ catch { /* an app without its canon installed is read by walking */ }
199
+ const files = [];
200
+ const visit = (directory) => {
201
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
202
+ const target = path.join(directory, entry.name);
203
+ const relative = relativePosix(folder, target);
204
+ if (entry.isDirectory()) {
205
+ if (!GENERATED.has(entry.name) && ignores?.(relative) !== true)
206
+ visit(target);
207
+ }
208
+ else if (entry.isFile() && isEmittableSource(entry.name) && (selects?.(relative) ?? true) && ignores?.(relative) !== true)
209
+ files.push(target);
210
+ }
211
+ };
212
+ visit(folder);
213
+ return { files: files.sort(), scope: selects === undefined ? 'walk' : 'canon' };
214
+ }
215
+ /** The app's own Prettier, from the first side or the root that installs one, or undefined. */
216
+ function prettierOf(root, folders) {
217
+ for (const folder of [...folders, root]) {
218
+ try {
219
+ return loadPrettier(folder);
220
+ }
221
+ catch { /* the next folder */ }
222
+ }
223
+ return undefined;
224
+ }
225
+ /** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
226
+ async function formatted(prettier, root, file, text) {
227
+ const ignorePath = path.join(root, '.prettierignore');
228
+ try {
229
+ const info = await prettier.getFileInfo(file, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
230
+ if (info.ignored || !info.inferredParser)
231
+ return text;
232
+ const options = (await prettier.resolveConfig(file, { editorconfig: true, useCache: true })) ?? {};
233
+ return await prettier.format(text, { ...options, filepath: file });
234
+ }
235
+ catch {
236
+ return text;
237
+ }
238
+ }
239
+ /** The side-relative name a report gives `absolute`. */
240
+ const nameOf = (root, absolute) => relativePosix(root, absolute);
241
+ /** Plans every open file of one program, checks that the rewritten files read no worse than the originals, and returns what each file becomes. */
242
+ async function migrateGroup({ ts, root, group, open, maxLength, report }) {
243
+ let program = programWith(ts, group, new Map());
244
+ const extra = ['react', 'next'].flatMap(name => {
245
+ const resolved = ts.resolveModuleName(name, open[0]?.absolute ?? root, group.options, ts.sys).resolvedModule?.resolvedFileName;
246
+ return resolved === undefined || programFile(program, resolved) !== undefined ? [] : [posix(resolved)];
247
+ });
248
+ if (extra.length) {
249
+ group.rootNames = [...group.rootNames, ...extra];
250
+ program = programWith(ts, group, new Map());
251
+ }
252
+ const checker = program.getTypeChecker();
253
+ const rewritten = new Map();
254
+ const planned = new Map();
255
+ for (const file of open) {
256
+ const source = programFile(program, file.absolute);
257
+ if (source === undefined || source.text !== file.text) {
258
+ report.findings.push(...file.problems.map(problem => ({ file: file.relative, line: 1, column: 1, code: 'NOT_IN_PROGRAM', name: problem.name, annotate: problem.annotate, reason: 'the file is not part of the side\'s TypeScript program', suggestion: 'include it in the tsconfig, then run the codemod again' })));
259
+ continue;
260
+ }
261
+ const context = { ts, program, checker, options: group.options, source, file: posix(file.absolute), text: file.text };
262
+ const plan = planFile(context, file.relative, file.problems, maxLength);
263
+ planned.set(file.absolute, plan);
264
+ if (plan.edits.length)
265
+ rewritten.set(file.absolute, applyEdits(file.text, [...plan.edits, ...importEdits(ts, source, file.text, plan.needs)]));
266
+ }
267
+ for (const [absolute, plan] of planned)
268
+ if (!rewritten.has(absolute))
269
+ report.findings.push(...plan.findings);
270
+ if (!rewritten.size)
271
+ return rewritten;
272
+ const after = programWith(ts, group, rewritten, program);
273
+ for (const [absolute, text] of [...rewritten]) {
274
+ const plan = planned.get(absolute);
275
+ const before = programFile(program, absolute);
276
+ const added = addedDiagnostic(diagnosticsOf(ts, program, before), diagnosticsOf(ts, after, programFile(after, absolute)));
277
+ if (added !== undefined) {
278
+ rewritten.delete(absolute);
279
+ report.findings.push(...plan.changes.map(change => ({ file: change.file, line: change.line, column: change.column, code: 'NOT_SOUND', name: change.name, annotate: change.code, reason: `writing \`${change.to}\` makes the compiler report: ${added}`, suggestion: 'write the type by hand' })), ...plan.findings);
280
+ continue;
281
+ }
282
+ report.changes.push(...plan.changes);
283
+ report.findings.push(...plan.findings);
284
+ const remaining = declarationSurface(ts, absolute, text, group.options.module)?.problems ?? [];
285
+ for (const problem of remaining) {
286
+ if (report.findings.some(found => found.file === nameOf(root, absolute) && found.name === problem.name && found.annotate === problem.annotate))
287
+ continue;
288
+ const place = ts.createSourceFile(absolute, text, ts.ScriptTarget.Latest).getLineAndCharacterOfPosition(problem.start);
289
+ report.findings.push({ file: nameOf(root, absolute), line: place.line + 1, column: place.character + 1, code: 'STILL_OPEN', name: problem.name, annotate: problem.annotate, reason: problem.reason, suggestion: SUGGESTIONS[problem.annotate] ?? 'annotate it by hand' });
290
+ }
291
+ }
292
+ return rewritten;
293
+ }
294
+ /**
295
+ * Preview or apply the explicit type annotations of one app: for every TypeScript source of each side that the declaration classifier counts as open
296
+ * (R368, HFS_EXPLICIT_EXPORT_SURFACE), the types the app's own checker infers are written where they are short, named and sound. Every other export is kept in the report.
297
+ */
298
+ export async function migrateExplicitSurface({ root, write = false, ts: given, maxTypeLength = MAX_TYPE_LENGTH }) {
299
+ const folders = sideFolders(root);
300
+ if (!folders.length)
301
+ throw new Error('explicit-surface runs at an app root with a be/ or fe/ directory');
302
+ const report = {
303
+ schema: 'starci/explicit-surface-report@1', root, write, maxTypeLength, filesScanned: 0, filesOpen: 0, filesChanged: [], filesStillOpen: 0, formatted: false, changes: [], findings: [],
304
+ };
305
+ const written = new Map();
306
+ for (const folder of folders) {
307
+ const ts = given ?? loadTypescript(folder, root, PACKAGE_ROOT);
308
+ if (!ts)
309
+ throw new Error('explicit-surface needs TypeScript installed in the app or @starci/hfs development checkout');
310
+ if (ts.transpileDeclaration === undefined)
311
+ throw new Error('explicit-surface needs TypeScript 5.5 or later, which writes declarations file by file');
312
+ const { files } = await sideSources(root, path.basename(folder), folder);
313
+ const groups = new Map();
314
+ for (const absolute of files) {
315
+ const text = fs.readFileSync(absolute, 'utf8');
316
+ report.filesScanned += 1;
317
+ if (GENERATED_HEADER.test(text.slice(0, 400)))
318
+ continue;
319
+ const config = owningConfig(absolute, root);
320
+ const entry = groups.get(config ?? '') ?? { config, files: [] };
321
+ entry.files.push(absolute);
322
+ groups.set(config ?? '', entry);
323
+ }
324
+ for (const { config, files: members } of groups.values()) {
325
+ const group = groupOf(ts, config, members);
326
+ const open = [];
327
+ for (const absolute of members) {
328
+ const text = fs.readFileSync(absolute, 'utf8');
329
+ const surface = declarationSurface(ts, posix(absolute), text, group.options.module);
330
+ if (surface !== null && surface.problems.length)
331
+ open.push({ absolute, relative: nameOf(root, absolute), text, problems: surface.problems });
332
+ }
333
+ report.filesOpen += open.length;
334
+ if (!open.length)
335
+ continue;
336
+ for (const [absolute, text] of await migrateGroup({ ts, root, group, open, maxLength: maxTypeLength, report }))
337
+ written.set(absolute, text);
338
+ }
339
+ }
340
+ const prettier = prettierOf(root, folders);
341
+ report.formatted = prettier !== undefined && written.size > 0;
342
+ for (const [absolute, text] of written) {
343
+ const final = prettier === undefined ? text : await formatted(prettier, root, absolute, text);
344
+ report.filesChanged.push(nameOf(root, absolute));
345
+ if (write)
346
+ fs.writeFileSync(absolute, final);
347
+ }
348
+ report.filesStillOpen = new Set(report.findings.map(finding => finding.file)).size;
349
+ report.changes.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
350
+ report.findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
351
+ report.filesChanged.sort();
352
+ return report;
353
+ }
354
+ const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
355
+ /** Render the annotations and what is left for a person as Markdown; JSON consumers read the same report object. */
356
+ export function explicitSurfaceMarkdown(report) {
357
+ const out = [
358
+ '# explicit-surface', '',
359
+ `Scanned ${report.filesScanned} files, ${report.filesOpen} open, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} annotations, ${report.findings.length} exports in ${report.filesStillOpen} files left for a person.`, '',
360
+ ];
361
+ if (report.findings.length) {
362
+ out.push('## Left for a person', '', '| location | export | code | reason | suggested fix |', '|---|---|---|---|---|');
363
+ for (const finding of report.findings)
364
+ out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${cell(finding.name)} | ${finding.code} | ${cell(finding.reason)} | ${cell(finding.suggestion)} |`);
365
+ out.push('');
366
+ }
367
+ if (report.changes.length) {
368
+ out.push('## Annotations', '', '| location | export | code | type written |', '|---|---|---|---|');
369
+ for (const item of report.changes)
370
+ out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${cell(item.name)} | ${item.code} | \`${cell(item.to)}\` |`);
371
+ out.push('');
372
+ }
373
+ return `${out.join('\n')}\n`;
374
+ }
@@ -1,16 +1,18 @@
1
1
  /** The codemods `starci app codemod` runs, by name. */
2
2
  export declare const CODEMODS: readonly string[];
3
- /** The arguments of `starci app codemod spacing-roles`, `theme-tokens`, `small-renames`, `page-navigation`, `section-frame`, `surface-card-kinds`, `tier-binding` and `loading-states`: --execute, --json, --format and --out; any other is refused. */
3
+ /** The arguments of every report-style `starci app codemod`: --execute, --json, --format and --out; any other is refused. */
4
4
  export declare function spacingRolesArguments(argv: readonly string[]): {
5
5
  write: boolean;
6
6
  format: 'json' | 'markdown';
7
7
  out?: string;
8
8
  };
9
+ type Io = {
10
+ cwd: string;
11
+ stdout: (text: string) => unknown;
12
+ };
9
13
  /**
10
14
  * CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
11
15
  * that leaves a case for a person exits 1 with the complete report.
12
16
  */
13
- export declare function codemodMain(argv: string[], io: {
14
- cwd: string;
15
- stdout: (text: string) => unknown;
16
- }): number;
17
+ export declare function codemodMain(argv: string[], io: Io): number | Promise<number>;
18
+ export {};
package/codemod/main.mjs CHANGED
@@ -7,11 +7,13 @@ import { migrateSectionFrame, sectionFrameMarkdown } from './section-frame.mjs';
7
7
  import { migrateSmallRenames, smallRenamesMarkdown } from './small-renames.mjs';
8
8
  import { migrateSpacingRoles, spacingRolesMarkdown } from './spacing-roles.mjs';
9
9
  import { migrateSurfaceCardKinds, surfaceCardKindsMarkdown } from './surface-card-kinds.mjs';
10
+ import { explicitSurfaceMarkdown, migrateExplicitSurface } from './explicit-surface.mjs';
11
+ import { directiveFirstMarkdown, migrateDirectiveFirst } from './directive-first.mjs';
10
12
  import { migrateThemeTokens, themeTokensMarkdown } from './theme-tokens.mjs';
11
13
  import { migrateTierBinding, tierBindingMarkdown } from './tier-binding.mjs';
12
14
  /** The codemods `starci app codemod` runs, by name. */
13
- export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states']);
14
- /** The arguments of `starci app codemod spacing-roles`, `theme-tokens`, `small-renames`, `page-navigation`, `section-frame`, `surface-card-kinds`, `tier-binding` and `loading-states`: --execute, --json, --format and --out; any other is refused. */
15
+ export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles', 'theme-tokens', 'small-renames', 'page-navigation', 'section-frame', 'surface-card-kinds', 'tier-binding', 'loading-states', 'explicit-surface', 'directive-first']);
16
+ /** The arguments of every report-style `starci app codemod`: --execute, --json, --format and --out; any other is refused. */
15
17
  export function spacingRolesArguments(argv) {
16
18
  const options = { write: false, format: 'markdown' };
17
19
  const args = argv.values();
@@ -44,6 +46,10 @@ export function spacingRolesArguments(argv) {
44
46
  }
45
47
  /** One of the report-style codemods run at an app root: its report object and the Markdown of it. */
46
48
  function runCodemod(name, root, write) {
49
+ if (name === 'directive-first') {
50
+ const report = migrateDirectiveFirst({ root, write });
51
+ return { report, markdown: directiveFirstMarkdown(report) };
52
+ }
47
53
  if (name === 'theme-tokens') {
48
54
  const report = migrateThemeTokens({ root, write });
49
55
  return { report, markdown: themeTokensMarkdown(report) };
@@ -75,6 +81,30 @@ function runCodemod(name, root, write) {
75
81
  const report = migrateSpacingRoles({ root, write });
76
82
  return { report, markdown: spacingRolesMarkdown(report) };
77
83
  }
84
+ /** The report written where the options say (`--out`) and to stdout; the exit code is 1 when a case is left for a person. */
85
+ function emit(options, result, io) {
86
+ const output = options.format === 'json' ? `${JSON.stringify(result.report, null, 2)}\n` : result.markdown;
87
+ if (options.out) {
88
+ const target = path.resolve(io.cwd, options.out);
89
+ fs.mkdirSync(path.dirname(target), { recursive: true });
90
+ fs.writeFileSync(target, output);
91
+ }
92
+ io.stdout(output);
93
+ return result.report.findings.length ? 1 : 0;
94
+ }
95
+ /** The arguments of a report-style codemod: spacingRolesArguments, and a report file that is JSON or Markdown. */
96
+ function reportArguments(argv) {
97
+ const options = spacingRolesArguments(argv);
98
+ if (options.out && !/\.(?:json|md)$/i.test(options.out))
99
+ throw new Error('--out needs a .json or .md report file');
100
+ return options;
101
+ }
102
+ /** `starci app codemod explicit-surface`: it reads the app's types through the compiler and formats with its Prettier, both asynchronous. */
103
+ async function explicitSurfaceMain(argv, io) {
104
+ const options = reportArguments(argv);
105
+ const report = await migrateExplicitSurface({ root: path.resolve(io.cwd), write: options.write });
106
+ return emit(options, { report, markdown: explicitSurfaceMarkdown(report) }, io);
107
+ }
78
108
  /**
79
109
  * CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
80
110
  * that leaves a case for a person exits 1 with the complete report.
@@ -83,19 +113,10 @@ export function codemodMain(argv, io) {
83
113
  const [name, ...rest] = argv;
84
114
  if (name === 'button-intent')
85
115
  return buttonIntentMain(argv, io);
86
- if (name !== 'spacing-roles' && name !== 'theme-tokens' && name !== 'small-renames' && name !== 'page-navigation' && name !== 'section-frame' && name !== 'surface-card-kinds' && name !== 'tier-binding' && name !== 'loading-states')
116
+ if (name === 'explicit-surface')
117
+ return explicitSurfaceMain(rest, io);
118
+ if (name === undefined || !CODEMODS.includes(name))
87
119
  throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
88
- const options = spacingRolesArguments(rest);
89
- if (options.out && !/\.(?:json|md)$/i.test(options.out))
90
- throw new Error('--out needs a .json or .md report file');
91
- const root = path.resolve(io.cwd);
92
- const { report, markdown } = runCodemod(name, root, options.write);
93
- const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : markdown;
94
- if (options.out) {
95
- const target = path.resolve(io.cwd, options.out);
96
- fs.mkdirSync(path.dirname(target), { recursive: true });
97
- fs.writeFileSync(target, output);
98
- }
99
- io.stdout(output);
100
- return report.findings.length ? 1 : 0;
120
+ const options = reportArguments(rest);
121
+ return emit(options, runCodemod(name, path.resolve(io.cwd), options.write), io);
101
122
  }
@@ -32,7 +32,7 @@ const VARIANT_REMOVED = new Set(['Input', 'Textarea', 'SearchField', 'NumberFiel
32
32
  'Select', 'ComboBox', 'DatePicker', 'DateRangePicker', 'CopyField', 'OtpInput', 'Tabs']);
33
33
  /** Components that lost `size="control"`: a bar sets it from context. */
34
34
  const CONTROL_SIZED = new Set(['Button', 'IconButton', 'Input', 'SearchField', 'SegmentedControl']);
35
- /** Components whose `control` step was their `lg`: the attribute becomes `size="lg"`, which draws the same. */
35
+ /** Components whose `control` step was their `lg`: the attribute becomes `size="lg"`, which draws the same, except in a navbar band, where `control` stays. */
36
36
  const CONTROL_IS_LG = new Set(['Switch', 'Avatar', 'AvatarButton']);
37
37
  const TONES = new Map([['live', 'success'], ['reconnecting', 'warning'], ['lost', 'danger']]);
38
38
  const OTP_RENAMES = new Map([['disabled', 'isDisabled'], ['invalid', 'isInvalid'], ['onChange', 'onValueChange']]);
@@ -188,6 +188,26 @@ export function transformSmallRenames({ ts, text, file = 'component.tsx' }) {
188
188
  return { name: tag.name.text, namespace: tag.expression.text, binding: null };
189
189
  return null;
190
190
  };
191
+ /**
192
+ * Whether `node` sits in a navbar band: the children of a TopBar, the `header` or `compactHeader` of a WorkspaceShell, or the `trigger` of a Drawer.
193
+ * A bar control keeps `size="control"`: `starci-grammar/navbar-control-size` requires it there and `starci-fe/control-height` accepts it,
194
+ * while `size="lg"` fails both.
195
+ */
196
+ const inBar = (node) => {
197
+ for (let at = node.parent; at && !ts.isSourceFile(at); at = at.parent) {
198
+ const bar = ts.isJsxElement(at) ? at.openingElement : ts.isJsxSelfClosingElement(at) ? at : null;
199
+ if (bar !== null && resolve(bar.tagName)?.name === 'TopBar')
200
+ return true;
201
+ if (ts.isJsxAttribute(at)) {
202
+ const owner = at.parent.parent;
203
+ const tag = ts.isJsxOpeningElement(owner) || ts.isJsxSelfClosingElement(owner) ? resolve(owner.tagName)?.name : undefined;
204
+ const prop = at.name.getText(source);
205
+ if ((tag === 'WorkspaceShell' && (prop === 'header' || prop === 'compactHeader')) || (tag === 'Drawer' && prop === 'trigger'))
206
+ return true;
207
+ }
208
+ }
209
+ return false;
210
+ };
191
211
  const conflict = (node, name, component) => { flag(node, 'IMPORT_NAME_CONFLICT', `${name} is already bound to something that is not the grammar export; rewrite by hand`, component); };
192
212
  // Exports renamed one to one.
193
213
  for (const binding of bindings.values()) {
@@ -235,7 +255,7 @@ export function transformSmallRenames({ ts, text, file = 'component.tsx' }) {
235
255
  flag(props.get('variant') ?? opening, 'BUTTON_VARIANT', 'run `starci app codemod button-intent` first: that codemod owns variant', component);
236
256
  if (CONTROL_SIZED.has(name) && sizeValue === 'control')
237
257
  drop('size', 'SIZE_CONTROL_REMOVED');
238
- if (CONTROL_IS_LG.has(name) && sizeValue === 'control') {
258
+ if (CONTROL_IS_LG.has(name) && sizeValue === 'control' && !inBar(opening)) {
239
259
  const attribute = props.get('size');
240
260
  if (attribute) {
241
261
  change(attribute, 'SIZE_CONTROL_LG', attribute.getText(source), 'size="lg"');
@@ -0,0 +1,8 @@
1
+ /** Folders no codemod enters: installed packages, build output and caches. */
2
+ export declare const GENERATED: Set<string>;
3
+ /** The folders of an app a codemod reads source from: the `be` and `fe` sides, else `src` of a single-side app, as absolute paths. */
4
+ export declare function sideFolders(root: string): string[];
5
+ /** Every file under `directory` whose name matches `pattern`, sorted, without descending into generated folders. */
6
+ export declare function filesUnder(directory: string, pattern: RegExp): string[];
7
+ /** The posix path of `target` relative to `root`, the way a report names a file. */
8
+ export declare const relativePosix: (root: string, target: string) => string;
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Folders no codemod enters: installed packages, build output and caches. */
4
+ export const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'build', 'out', 'coverage', '.git', 'storybook-static']);
5
+ /** The folders of an app a codemod reads source from: the `be` and `fe` sides, else `src` of a single-side app, as absolute paths. */
6
+ export function sideFolders(root) {
7
+ const isFolder = (folder) => fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory() === true;
8
+ const sides = ['be', 'fe'].map(name => path.join(root, name)).filter(isFolder);
9
+ if (sides.length)
10
+ return sides;
11
+ return ['src'].map(name => path.join(root, name)).filter(isFolder);
12
+ }
13
+ /** Every file under `directory` whose name matches `pattern`, sorted, without descending into generated folders. */
14
+ export function filesUnder(directory, pattern) {
15
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
16
+ const target = path.join(directory, entry.name);
17
+ if (entry.isDirectory())
18
+ return GENERATED.has(entry.name) ? [] : filesUnder(target, pattern);
19
+ return entry.isFile() && pattern.test(entry.name) ? [target] : [];
20
+ }).sort();
21
+ }
22
+ /** The posix path of `target` relative to `root`, the way a report names a file. */
23
+ export const relativePosix = (root, target) => path.relative(root, target).split(path.sep).join('/');