@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.
- package/CHANGELOG.md +21 -0
- package/README.md +7 -8
- package/cache/fake-session.mjs +1 -1
- package/cache/hfs-family.mjs +22 -9
- package/cache/import-graph.d.mts +32 -4
- package/cache/import-graph.mjs +88 -11
- package/cache/lint-family.d.mts +10 -3
- package/cache/lint-family.mjs +65 -37
- package/cache/records.d.mts +7 -0
- package/cache/records.mjs +17 -1
- package/cache/run.d.mts +1 -1
- package/cache/session.mjs +6 -2
- package/cache/side-key.d.mts +2 -0
- package/cache/side-key.mjs +5 -1
- package/cache/structure-memo.d.mts +34 -0
- package/cache/structure-memo.mjs +150 -0
- package/cache/types.d.mts +6 -2
- package/cache/wiring-family.mjs +1 -1
- package/codemod/directive-first.d.mts +48 -0
- package/codemod/directive-first.mjs +136 -0
- package/codemod/explicit-surface-edit.d.mts +14 -0
- package/codemod/explicit-surface-edit.mjs +57 -0
- package/codemod/explicit-surface-names.d.mts +30 -0
- package/codemod/explicit-surface-names.mjs +135 -0
- package/codemod/explicit-surface-render.d.mts +29 -0
- package/codemod/explicit-surface-render.mjs +197 -0
- package/codemod/explicit-surface.d.mts +50 -0
- package/codemod/explicit-surface.mjs +374 -0
- package/codemod/main.d.mts +7 -5
- package/codemod/main.mjs +37 -16
- package/codemod/small-renames.mjs +22 -2
- package/codemod/source-tree.d.mts +8 -0
- package/codemod/source-tree.mjs +23 -0
- package/lint/eslint-list.cjs +55 -0
- package/lint/run.d.mts +35 -61
- package/lint/run.mjs +91 -288
- package/package.json +1 -1
- package/release/lint-report.d.mts +1 -1
- package/release/lint-report.mjs +3 -3
- package/report/sonar.d.mts +1 -1
- package/report/sonar.mjs +3 -3
- package/report/stylelint-findings.d.mts +23 -0
- package/report/stylelint-findings.mjs +9 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +56 -5
- package/runtime/knowledge/hfs/slots.yaml +1 -1
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/scripts/api/node/run-node.d.mts +5 -0
- package/runtime/scripts/hfs/architecture/test-world-files.d.mts +4 -3
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +26 -7
- package/runtime/scripts/hfs/check.d.mts +16 -1
- package/runtime/scripts/hfs/check.mjs +18 -7
- package/runtime/scripts/hfs/explicit-surface-rule.d.mts +58 -0
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +59 -0
- package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
- package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
- package/runtime/scripts/hfs/rule-memo.mjs +24 -0
- package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
- package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
- package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
- package/runtime/scripts/hfs/rules/read.d.mts +5 -0
- package/runtime/scripts/hfs/rules/read.mjs +13 -0
- package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
- package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
- package/runtime/scripts/lib/declaration-surface.d.mts +35 -0
- package/runtime/scripts/lib/declaration-surface.mjs +112 -0
- package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
- package/scaffold/app.mjs +3 -3
- package/src/check-cache-structure-support.d.mts +52 -0
- package/src/main.d.mts +3 -2
- package/src/main.mjs +123 -109
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +19 -20
- package/sync/index.mjs +2 -2
- package/templates/app/package-scripts/core.json +2 -2
- package/templates/app/task-graph/turbo.json +0 -3
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +2 -2
- package/templates/be/inbox/entities.index.ts.tpl +1 -1
- package/templates/be/inbox/module-definition.ts.tpl +8 -2
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +10 -2
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +8 -2
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +8 -3
- package/templates/be/patterns/event-bus/platform/persistence/connection.ts.tpl +3 -2
- package/templates/be/patterns/event-bus/platform/persistence/event-bus.sql.ts.tpl +4 -3
- package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +8 -3
- package/templates/be/patterns/idempotency/platform/inbox/persistence/connection.ts.tpl +3 -2
- package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.sql.ts.tpl +7 -6
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/jobs/job.processor.ts.tpl +1 -1
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +8 -3
- package/templates/be/patterns/jobs/platform/persistence.connection.ts.tpl +3 -2
- package/templates/be/patterns/jobs/platform/persistence.jobs.sql.ts.tpl +5 -4
- package/templates/be/patterns/projections/persistence.connection.ts.tpl +3 -2
- package/templates/be/patterns/projections/projection.module-definition.ts.tpl +8 -3
- package/templates/be/patterns/queues/platform/persistence.connection.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/persistence.queue.sql.ts.tpl +4 -3
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +8 -3
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/reactors/consumer.ts.tpl +1 -1
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/saga/saga.done.consumer.ts.tpl +1 -1
- package/templates/be/patterns/saga/saga.failed.consumer.ts.tpl +1 -1
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +9 -3
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +9 -3
- package/templates/be/platform/graphql/graphql.module-definition.ts +8 -3
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +9 -3
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +9 -3
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +9 -3
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +9 -3
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +9 -3
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/domain/note/persistence/connection.ts +3 -2
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.sql.ts +2 -1
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +10 -4
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +8 -4
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +10 -4
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +10 -4
- package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +8 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +5 -1
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +5 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +7 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +7 -1
- package/templates/be/table/api/feature.module-definition.ts.tpl +9 -3
- package/templates/be/table/api/graphql.module-definition.ts.tpl +9 -3
- package/templates/be/table/entities.index.ts.tpl +1 -1
- package/templates/be/table/module-definition.ts.tpl +8 -3
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +1 -0
- package/templates/be/test-world/world/test-world.config.ts.tpl +8 -1
- package/templates/fe/common/app-layout.tsx.partial +1 -1
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +2 -1
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +11 -3
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +8 -2
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +2 -1
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +1 -1
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +4 -1
- package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +3 -2
- package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +12 -4
- package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +8 -3
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +4 -3
- package/templates/fe/skeleton/apps/app/src/features/layouts/AppLayout/index.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +2 -1
- package/templates/fe/skeleton/apps/app/src/features/pages/AppLoadingPage/index.tsx +2 -1
- package/templates/fe/skeleton/apps/app/src/features/pages/AppNotFoundPage/index.tsx +2 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +2 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/request.ts +4 -1
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +4 -3
- package/templates/fe/skeleton/apps/landing/src/features/layouts/LandingLayout/index.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/features/pages/LandingLoadingPage/index.tsx +2 -1
- package/templates/fe/skeleton/apps/landing/src/features/pages/LandingNotFoundPage/index.tsx +2 -1
- package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/index.tsx +2 -1
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +2 -1
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/request.ts +4 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +19 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +5 -1
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +4 -3
- package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppLoadingPage/index.tsx +2 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppNotFoundPage/index.tsx +2 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +3 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +5 -1
- package/templates/fe/table/graphql/useMutateSwr.ts.tpl +2 -1
- package/templates/fe/table/graphql/useQuerySwr.ts.tpl +2 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/error.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/http.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/locale.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/refresh-token.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/retry.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/timeout.ts +1 -1
- package/templates/fe/units/page/index.tsx.tpl +3 -2
- 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
|
+
}
|
package/codemod/main.d.mts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
89
|
-
|
|
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('/');
|