@starci/hfs 5.0.8 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/cache/import-graph.mjs +6 -61
- package/cache/lint-family.d.mts +10 -1
- package/cache/lint-family.mjs +12 -5
- 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/source-tree.d.mts +8 -0
- package/codemod/source-tree.mjs +23 -0
- package/lint/eslint-list.cjs +29 -0
- package/lint/run.d.mts +19 -5
- package/lint/run.mjs +47 -14
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +52 -1
- 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/explicit-surface-rule.d.mts +58 -0
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +59 -0
- package/runtime/scripts/lib/declaration-surface.d.mts +35 -0
- package/runtime/scripts/lib/declaration-surface.mjs +112 -0
- 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,197 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { declarationOf, exportedSymbol, moduleFile, nameAnonymousParts, namesInScope, relativeSpecifier } from './explicit-surface-names.mjs';
|
|
3
|
+
/** The longest type the codemod writes on one line; a longer one is a type a person names. */
|
|
4
|
+
export const MAX_TYPE_LENGTH = 100;
|
|
5
|
+
const refuse = (code, reason, suggestion) => ({ ok: false, code, reason, suggestion });
|
|
6
|
+
const posix = (file) => file.replaceAll('\\', '/');
|
|
7
|
+
const ABSOLUTE = /^(?:[A-Za-z]:)?\//;
|
|
8
|
+
const JSX_ELEMENT = new Set(['JSX.Element', 'React.JSX.Element']);
|
|
9
|
+
/** The text of an entity name (`A.B.C`). */
|
|
10
|
+
function entityText(ts, name) {
|
|
11
|
+
return ts.isIdentifier(name) ? name.text : `${entityText(ts, name.left)}.${name.right.text}`;
|
|
12
|
+
}
|
|
13
|
+
/** The leftmost identifier of an entity name. */
|
|
14
|
+
function rootIdentifier(ts, name) {
|
|
15
|
+
return ts.isIdentifier(name) ? name : rootIdentifier(ts, name.left);
|
|
16
|
+
}
|
|
17
|
+
/** The package specifier of a file under `node_modules` (`@types/react` is `react`, `@types/a__b` is `@a/b`), and the path inside it. */
|
|
18
|
+
function packageOf(file) {
|
|
19
|
+
const at = file.lastIndexOf('/node_modules/');
|
|
20
|
+
if (at < 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
const parts = file.slice(at + '/node_modules/'.length).split('/');
|
|
23
|
+
const scoped = parts[0]?.startsWith('@') === true;
|
|
24
|
+
let name = parts.slice(0, scoped ? 2 : 1).join('/');
|
|
25
|
+
const typed = /^@types\/(.+)$/.exec(name);
|
|
26
|
+
if (typed?.[1] !== undefined)
|
|
27
|
+
name = typed[1].includes('__') ? `@${typed[1].replace('__', '/')}` : typed[1];
|
|
28
|
+
return { name, inner: parts.slice(scoped ? 2 : 1).join('/').replace(/\.(?:d\.)?[cm]?tsx?$/, '').replace(/\/?index$/, '') };
|
|
29
|
+
}
|
|
30
|
+
/** The specifiers an import of `target` from `c.file` can be written with, best first: a package name, a path alias the file already uses, a relative path. */
|
|
31
|
+
function specifierCandidates(c, target) {
|
|
32
|
+
const installed = packageOf(posix(target.fileName));
|
|
33
|
+
if (installed !== undefined)
|
|
34
|
+
return [installed.name, ...(installed.inner ? [`${installed.name}/${installed.inner}`] : [])];
|
|
35
|
+
const relative = relativeSpecifier(c.file, target.fileName);
|
|
36
|
+
const nodeNext = c.options.moduleResolution === c.ts.ModuleResolutionKind.NodeNext || c.options.moduleResolution === c.ts.ModuleResolutionKind.Node16;
|
|
37
|
+
const extension = /\.([cm]?)tsx?$/.exec(target.fileName);
|
|
38
|
+
const written = nodeNext ? `${relative}.${extension?.[1] === 'm' ? 'mjs' : extension?.[1] === 'c' ? 'cjs' : 'js'}` : relative;
|
|
39
|
+
const aliases = [];
|
|
40
|
+
const base = c.options.baseUrl ?? c.options['pathsBasePath'] ?? path.dirname(c.file);
|
|
41
|
+
const bare = posix(target.fileName).replace(/\.(?:d\.)?[cm]?tsx?$/, '');
|
|
42
|
+
for (const [pattern, targets] of Object.entries(c.options.paths ?? {})) {
|
|
43
|
+
for (const to of targets) {
|
|
44
|
+
const [head = '', tail = ''] = to.split('*');
|
|
45
|
+
const prefix = posix(path.resolve(base, head)) + (head.endsWith('/') ? '/' : '');
|
|
46
|
+
if (to.includes('*') && bare.startsWith(prefix) && bare.endsWith(tail))
|
|
47
|
+
aliases.push(pattern.replace('*', bare.slice(prefix.length, bare.length - tail.length)));
|
|
48
|
+
else if (!to.includes('*') && bare === posix(path.resolve(base, to)).replace(/\.(?:d\.)?[cm]?tsx?$/, ''))
|
|
49
|
+
aliases.push(pattern);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const usesAlias = c.source.statements.some(statement => c.ts.isImportDeclaration(statement) && c.ts.isStringLiteral(statement.moduleSpecifier)
|
|
53
|
+
&& Object.keys(c.options.paths ?? {}).some(pattern => statement.moduleSpecifier.getText(c.source).slice(1, -1).startsWith(pattern.replace('*', ''))));
|
|
54
|
+
return usesAlias ? [...aliases, written] : [written, ...aliases];
|
|
55
|
+
}
|
|
56
|
+
/** Whether `specifier`, written in `c.file`, reaches the same declaration of `name` that `target` holds. */
|
|
57
|
+
function reaches(c, specifier, name, target) {
|
|
58
|
+
const resolved = c.ts.resolveModuleName(specifier, c.file, c.options, c.ts.sys).resolvedModule?.resolvedFileName;
|
|
59
|
+
const file = resolved === undefined ? undefined : c.program.getSourceFile(posix(resolved));
|
|
60
|
+
return file !== undefined && exportedSymbol(c, file, name) === target;
|
|
61
|
+
}
|
|
62
|
+
/** The module file a raw specifier of the checker's import type names: an absolute path without extension, or a module name. */
|
|
63
|
+
function targetModule(c, raw) {
|
|
64
|
+
if (ABSOLUTE.test(raw))
|
|
65
|
+
return moduleFile(c, raw);
|
|
66
|
+
const resolved = c.ts.resolveModuleName(raw, c.file, c.options, c.ts.sys).resolvedModule?.resolvedFileName;
|
|
67
|
+
return resolved === undefined ? undefined : c.program.getSourceFile(posix(resolved));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* How `name`, exported by the module the checker named with `raw`, is written in the file: an import specifier to add, `null` when the file already has the
|
|
71
|
+
* name bound to that very declaration, or a refusal when the name is taken by something else or no specifier of the file reaches it.
|
|
72
|
+
*/
|
|
73
|
+
function resolveNeed(c, scope, name, raw) {
|
|
74
|
+
const file = targetModule(c, raw);
|
|
75
|
+
const target = file === undefined ? undefined : exportedSymbol(c, file, name);
|
|
76
|
+
if (file === undefined || target === undefined)
|
|
77
|
+
return refuse('TYPE_UNNAMEABLE', `\`${name}\` is not exported by a module the file can import`, `export ${name} from its module, or declare a named type in this file`);
|
|
78
|
+
const taken = scope.get(name);
|
|
79
|
+
if (taken !== undefined) {
|
|
80
|
+
if (declarationOf(c, taken) === declarationOf(c, target))
|
|
81
|
+
return { specifier: null };
|
|
82
|
+
const global = taken.declarations?.every(declaration => !c.ts.isExternalModule(declaration.getSourceFile())) === true;
|
|
83
|
+
if (!global || new RegExp(`\\b${name}\\b`).test(c.text))
|
|
84
|
+
return refuse('TYPE_UNNAMEABLE', `\`${name}\` is already another name in this file`, `rename one of the two, or declare a named type in this file`);
|
|
85
|
+
}
|
|
86
|
+
const specifier = specifierCandidates(c, file).find(candidate => reaches(c, candidate, name, target));
|
|
87
|
+
return specifier === undefined ? refuse('TYPE_UNNAMEABLE', `no import path of this file reaches \`${name}\` (${posix(file.fileName)})`, `export ${name} from a module this file can import, or declare a named type in this file`) : { specifier };
|
|
88
|
+
}
|
|
89
|
+
/** Writes the checker's type node as source: import types become names plus imports, `JSX.Element` becomes `ReactElement`, and anonymous, `any` and non-nameable parts are refused. */
|
|
90
|
+
function rewrite(c, node, scope, needs) {
|
|
91
|
+
const { ts } = c;
|
|
92
|
+
let refused;
|
|
93
|
+
const need = (name, raw) => {
|
|
94
|
+
const resolved = resolveNeed(c, scope, name, raw);
|
|
95
|
+
if ('ok' in resolved)
|
|
96
|
+
refused ??= resolved;
|
|
97
|
+
else if (resolved.specifier !== null && !needs.some(entry => entry.name === name && entry.specifier === resolved.specifier))
|
|
98
|
+
needs.push({ name, specifier: resolved.specifier });
|
|
99
|
+
};
|
|
100
|
+
// The checker names a type by an identifier that carries its symbol: a name the file cannot write is imported from the module that exports it.
|
|
101
|
+
const named = (id) => {
|
|
102
|
+
const symbol = id.symbol;
|
|
103
|
+
if (symbol === undefined)
|
|
104
|
+
return;
|
|
105
|
+
if ((symbol.flags & ts.SymbolFlags.TypeParameter) !== 0)
|
|
106
|
+
return;
|
|
107
|
+
const taken = scope.get(id.text);
|
|
108
|
+
if (taken !== undefined && declarationOf(c, taken) === declarationOf(c, symbol))
|
|
109
|
+
return;
|
|
110
|
+
const file = symbol.declarations?.[0]?.getSourceFile();
|
|
111
|
+
if (file !== undefined && ts.isExternalModule(file))
|
|
112
|
+
need(id.text, file.fileName);
|
|
113
|
+
};
|
|
114
|
+
const result = ts.transform(node, [context => root => {
|
|
115
|
+
const visit = (child) => {
|
|
116
|
+
if (ts.isImportTypeNode(child)) {
|
|
117
|
+
const literal = ts.isLiteralTypeNode(child.argument) && ts.isStringLiteral(child.argument.literal) ? child.argument.literal.text : undefined;
|
|
118
|
+
if (child.isTypeOf || child.qualifier === undefined || literal === undefined) {
|
|
119
|
+
refused ??= refuse('TYPE_UNNAMEABLE', 'the type is a module or a `typeof import(...)`', 'declare a named type and annotate with it');
|
|
120
|
+
return child;
|
|
121
|
+
}
|
|
122
|
+
const args = child.typeArguments?.map(argument => visit(argument));
|
|
123
|
+
if (JSX_ELEMENT.has(entityText(ts, child.qualifier))) {
|
|
124
|
+
need('ReactElement', 'react');
|
|
125
|
+
return context.factory.createTypeReferenceNode('ReactElement');
|
|
126
|
+
}
|
|
127
|
+
need(rootIdentifier(ts, child.qualifier).text, literal);
|
|
128
|
+
return context.factory.createTypeReferenceNode(child.qualifier, args);
|
|
129
|
+
}
|
|
130
|
+
if (ts.isTypeReferenceNode(child) && JSX_ELEMENT.has(entityText(ts, child.typeName))) {
|
|
131
|
+
need('ReactElement', 'react');
|
|
132
|
+
return context.factory.createTypeReferenceNode('ReactElement');
|
|
133
|
+
}
|
|
134
|
+
if (ts.isTypeReferenceNode(child))
|
|
135
|
+
named(rootIdentifier(ts, child.typeName));
|
|
136
|
+
if (ts.isTypeQueryNode(child))
|
|
137
|
+
named(rootIdentifier(ts, child.exprName));
|
|
138
|
+
if (child.kind === ts.SyntaxKind.AnyKeyword) {
|
|
139
|
+
refused ??= refuse('TYPE_ANY', 'the inferred type contains `any`', 'write the type the value really has; an `any` on a surface hides the contract');
|
|
140
|
+
return child;
|
|
141
|
+
}
|
|
142
|
+
if (ts.isTypeLiteralNode(child) && child.members.length > 0) {
|
|
143
|
+
refused ??= refuse('TYPE_ANONYMOUS', 'the inferred type is an anonymous object type', 'declare a named type (`type X = ...`), export it and annotate with it');
|
|
144
|
+
return child;
|
|
145
|
+
}
|
|
146
|
+
if (ts.isMappedTypeNode(child) || ts.isConditionalTypeNode(child) || (ts.isTypeOperatorNode(child) && child.operator === ts.SyntaxKind.UniqueKeyword)) {
|
|
147
|
+
refused ??= refuse('TYPE_ANONYMOUS', 'the inferred type is computed (mapped, conditional or `unique symbol`)', 'declare a named type for it and annotate with it');
|
|
148
|
+
return child;
|
|
149
|
+
}
|
|
150
|
+
return ts.visitEachChild(child, visit, context);
|
|
151
|
+
};
|
|
152
|
+
return visit(root);
|
|
153
|
+
}]);
|
|
154
|
+
return refused ?? { node: result.transformed[0] };
|
|
155
|
+
}
|
|
156
|
+
/** The Next.js export conventions whose type the framework names: the export, its type in `next`, and whether it is a function. */
|
|
157
|
+
const CONVENTIONS = new Map([
|
|
158
|
+
['generateMetadata', { type: 'Metadata', kind: 'function' }], ['metadata', { type: 'Metadata', kind: 'variable' }],
|
|
159
|
+
['generateViewport', { type: 'Viewport', kind: 'function' }], ['viewport', { type: 'Viewport', kind: 'variable' }],
|
|
160
|
+
]);
|
|
161
|
+
/**
|
|
162
|
+
* The type Next.js names for a route export (`generateMetadata`: `Promise<Metadata>`, `metadata`: `Metadata`, and the viewport pair), when `next` is installed and the inferred
|
|
163
|
+
* type is assignable to it; undefined for any other export, so the inferred type is written instead.
|
|
164
|
+
*/
|
|
165
|
+
export function conventionType(c, name, inferred, enclosing, kind) {
|
|
166
|
+
const row = CONVENTIONS.get(name);
|
|
167
|
+
const file = row?.kind === kind ? targetModule(c, 'next') : undefined;
|
|
168
|
+
const symbol = row === undefined || file === undefined ? undefined : exportedSymbol(c, file, row.type);
|
|
169
|
+
if (row === undefined || symbol === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
const awaited = kind === 'function' ? c.checker.getAwaitedType(inferred) : inferred;
|
|
172
|
+
if (awaited === undefined || !c.checker.isTypeAssignableTo(awaited, c.checker.getDeclaredTypeOfSymbol(symbol)))
|
|
173
|
+
return undefined;
|
|
174
|
+
const resolved = resolveNeed(c, namesInScope(c, enclosing), row.type, 'next');
|
|
175
|
+
if ('ok' in resolved)
|
|
176
|
+
return undefined;
|
|
177
|
+
const written = awaited === inferred ? row.type : `Promise<${row.type}>`;
|
|
178
|
+
return { ok: true, text: written, needs: resolved.specifier === null ? [] : [{ name: row.type, specifier: resolved.specifier }] };
|
|
179
|
+
}
|
|
180
|
+
/** Writes `type`, as it is seen from `enclosing`, as source text: named types by name, the imports they need, or the reason it is left to a person. */
|
|
181
|
+
export function renderType(c, type, enclosing, maxLength = MAX_TYPE_LENGTH) {
|
|
182
|
+
const { ts } = c;
|
|
183
|
+
if ((type.flags & ts.TypeFlags.Any) !== 0)
|
|
184
|
+
return refuse('TYPE_ANY', 'the inferred type is `any`', 'write the type the value really has; an `any` on a surface hides the contract');
|
|
185
|
+
const flags = ts.NodeBuilderFlags.NoTruncation;
|
|
186
|
+
const drawn = c.checker.typeToTypeNode(type, enclosing, flags | ts.NodeBuilderFlags.UseAliasDefinedOutsideCurrentScope) ?? c.checker.typeToTypeNode(type, enclosing, flags);
|
|
187
|
+
if (drawn === undefined)
|
|
188
|
+
return refuse('TYPE_UNNAMEABLE', 'the compiler cannot write the type', 'declare a named type and annotate with it');
|
|
189
|
+
const needs = [];
|
|
190
|
+
const rewritten = rewrite(c, nameAnonymousParts(c, drawn, type, enclosing), namesInScope(c, enclosing), needs);
|
|
191
|
+
if ('ok' in rewritten)
|
|
192
|
+
return rewritten;
|
|
193
|
+
const text = ts.createPrinter({ removeComments: true }).printNode(ts.EmitHint.Unspecified, rewritten.node, c.source);
|
|
194
|
+
if (text.length > maxLength)
|
|
195
|
+
return refuse('TYPE_TOO_LONG', `the type is ${text.length} characters (${text.slice(0, 60)}...)`, 'declare a named type (`type X = ...`), export it and annotate with it');
|
|
196
|
+
return { ok: true, text, needs };
|
|
197
|
+
}
|
|
@@ -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 {};
|