diff --git a/README.md b/README.md index c1eadb1..b6faf20 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Solid Migration Assistant -Solid Migration Assistant is an experimental, read-only analyzer for selected Solid 1.9 migration sites targeting Solid `2.0.0-rc.0`. +Solid Migration Assistant is an experimental Solid 1.9 → Solid 2 migration assistant targeting Solid `2.0.0-rc.0`. It ships two workflows: a read-only `analyze` workflow that prints guidance for supported migration sites, and a deterministic `transform` workflow that relocates a small, pure subset of legacy import subpaths. -The assistant scans project-owned `.js`, `.jsx`, `.ts`, and `.tsx` source, prints one detailed guidance string for each supported detection, and exits successfully when migration work is found. Guidance is sorted deterministically and printed to standard output; the Codemod runtime's progress lines and the final disclosure are written to standard error. The analyzer never edits the target and does not generate reports, dashboards, or other output there. Codemod analytics are disabled. Codemod may persist workflow and task state in normal platform user-data directories outside the target; the assistant does not redirect or remove that runtime state. +The assistant scans project-owned `.js`, `.jsx`, `.ts`, and `.tsx` source, prints one detailed guidance string for each supported detection, and exits successfully when migration work is found. Guidance is sorted deterministically and printed to standard output; the Codemod runtime's progress lines and the final disclosure are written to standard error. The analyzer never edits the target and generates no artifact unless `--report FILE` is explicitly supplied. Codemod analytics are disabled. Codemod may persist workflow and task state in normal platform user-data directories outside the target; the assistant does not redirect or remove that runtime state. -## Run the RC analyzer +## Run the RC analyzer and transform -> **RC scope:** this `0.2.1` analyzer targets Solid `2.0.0-rc.0`, scans project-owned `.js`, `.jsx`, `.ts`, and `.tsx` source, and covers only the detections listed below. A clean run is not proof that a project is ready for Solid 2. +> **RC scope:** version `0.3.0` targets Solid `2.0.0-rc.0` and covers only the detections and relocations documented below. A clean analyzer run is not proof that a project is ready for Solid 2. After npm publication, run the package from a project root with Node 20 or newer and npm (no pnpm installation is needed): @@ -22,6 +22,14 @@ The current directory is analyzed by default. To analyze another directory: npx --yes solid-migration-assistant@latest --target /path/to/a/solid-project ``` +To keep the same terminal guidance and also write the selected-rule pilot dashboard: + +```sh +npx --yes solid-migration-assistant@latest --target . --report migration-report.html +``` + +The portable HTML covers `web-import`, `component-renames`, `create-effect`, and read-only legacy-subpath relocation previews. It contains the full matched source line range plus one complete context line before and after every finding. Treat it as project source. Existing files are refused unless `--force` is explicit; browsers open only with `--open`. + The supported rules detect the complete Solid 2 RC migration quick rename / removal map: **Imports** — `solid-js/web`, store, renderer, and JSX-runtime subpath repackaging. @@ -30,6 +38,26 @@ The supported rules detect the complete Solid 2 RC migration quick rename / remo **Props & store** — `mergeProps`/`splitProps`, `onMount`, `unwrap`, `produce`, and `createMutable`/`modifyMutable`. Coverage is deliberately limited. Even when no guidance is printed, review the documented exclusions and perform the application's normal type, build, and behavior validation; a clean analyzer run is not a readiness result. +## Transform + +The opt-in `transform` workflow rewrites exactly five pure legacy Solid import subpaths in place and changes nothing else: + +- `solid-js/h` → `@solidjs/h` +- `solid-js/html` → `@solidjs/html` +- `solid-js/universal` → `@solidjs/universal` +- `solid-js/jsx-runtime` → `@solidjs/web/jsx-runtime` +- `solid-js/jsx-dev-runtime` → `@solidjs/web/jsx-dev-runtime` + +It covers static imports, re-exports, dynamic `import()`, and `require()` calls; preserves each reference's import form and quote style; and emits one per-edit report line (`file:line:column`, old → new, plus the migration-guide link). Every move is a pure package relocation with no removed, renamed, or behaviorally changed export, so no binding-level review is required for these five paths. The workflow is idempotent and writes no report files or other artifacts in the target. It deliberately leaves `solid-js/web`, `solid-js/store`, already-migrated paths, and near-miss subpaths such as `solid-js/h-extra` and `vendor/solid-js/h` untouched. + +In this repository, run `pnpm transform` against the current directory, or invoke the Codemod CLI directly to target another directory: + +```sh +node ./node_modules/codemod/codemod --disable-analytics workflow run -w transform.yaml -t /path/to/a/solid-project --allow-dirty --no-interactive +``` + +After publication, select the `transform` workflow from the Codemod platform (it is registered with `default: false`). + ## Verify the repository ```sh @@ -37,8 +65,8 @@ pnpm install --frozen-lockfile pnpm verify ``` -Verification runs comprehensive rule fixtures and an end-to-end project fixture, checks exact ordered guidance across repeated runs, and proves analysis leaves every target file unchanged. +Verification runs comprehensive analysis and transformation rule fixtures plus end-to-end analyzer and transform fixtures; it checks exact ordered guidance, proves the analyzer leaves every target file unchanged, and proves the transform is idempotent and changes nothing outside the five relocated module strings. See [`codemods/solid-migration-assistant/README.md`](codemods/solid-migration-assistant/README.md) for rule boundaries. Feedback is collected through ordinary [public GitHub issues](https://github.com/devagrawal09/solid-migration-assistant/issues/new). -Automated migrations are a possible future roadmap item; this preview ships no executable transforms. The project is licensed under the [MIT License](LICENSE). +Broader automated migrations remain a possible future roadmap item. The project is licensed under the [MIT License](LICENSE). diff --git a/codemods/solid-migration-assistant/README.md b/codemods/solid-migration-assistant/README.md index cbbdc1a..74e9df8 100644 --- a/codemods/solid-migration-assistant/README.md +++ b/codemods/solid-migration-assistant/README.md @@ -1,12 +1,12 @@ # Solid Migration Assistant -This package implements Solid Migration Assistant as a single read-only workflow for a narrow Solid 1.9 client-application profile. It scans project-owned `.js`, `.jsx`, `.ts`, and `.tsx` source files and prints one detailed, location-bearing guidance string per supported migration site. The workflow returns no edits and writes no files. +This package implements Solid Migration Assistant as two workflows for a narrow Solid 1.9 client-application profile. The read-only `analyze` workflow scans project-owned `.js`, `.jsx`, `.ts`, and `.tsx` source files and prints one detailed, location-bearing guidance string per supported migration site; without an explicit report option it returns no edits and writes no files. The `transform` workflow deterministically relocates a small, pure subset of legacy import subpaths. The migration target is pinned to Solid `2.0.0-rc.0` at upstream commit [`ff4d3c44`](https://github.com/solidjs/solid/tree/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5). ## Analyze with npm -> **RC scope:** version `0.2.1` targets Solid `2.0.0-rc.0`, analyzes project-owned `.js`, `.jsx`, `.ts`, and `.tsx` source, and implements only the detections documented below. A clean run is not proof that a project is ready for Solid 2. +> **RC scope:** version `0.3.0` targets Solid `2.0.0-rc.0` and implements only the detections and relocations documented below. A clean run is not proof that a project is ready for Solid 2. After npm publication, run this from the project root with Node 20 or newer and npm (pnpm is not required): @@ -22,6 +22,20 @@ The current directory is the default target. An explicit target may be absolute npx --yes solid-migration-assistant@latest --target /path/to/a/solid-project ``` +### Portable pilot report + +Add `--report FILE` to keep all current terminal guidance and also write one portable, hash-routed HTML report for that immutable run: + +```sh +npx --yes solid-migration-assistant@latest --target . --report migration-report.html +``` + +The pilot dashboard includes only `web-import`, `component-renames`, `create-effect`, and the read-only `legacy-subpath-relocation` edit preview. Other supported rules remain terminal-only; their absence from the dashboard is not a clean result or a full-migration claim. The preview proposes safe relocation edits but never applies them during analysis. + +The command refuses an existing destination. Add `--force` to replace it atomically. It never launches a browser unless `--open` is also supplied. Every generated HTML embeds project source—the complete matched line range plus one complete line before and after—and the absolute analyzed target path used by editor links. Treat and share the report as project source and local machine metadata. VS Code actions use that fixed generation-time path; if the project is moved or the report is opened on another machine, those links will not point at the project copy there. + +The dashboard starts in the current operating-system light or dark preference. Its accessible theme switch changes only the open page; the choice is not stored, so reload and reopen return to the current system preference. Each finding offers a direct VS Code deep link and a small extensible overflow menu with a portable `relative/path:line:column` copy fallback. The header’s wrapped target-root utility row also copies the fixed absolute analyzed root. + A run with detections exits successfully. Complete opaque guidance strings are exact-deduplicated, sorted lexically as whole strings, and printed once to standard output as a terminal aggregate. The Codemod runtime's progress lines and the final disclosure are written to standard error. To capture the findings in a file, redirect standard output: `npx --yes solid-migration-assistant@latest --target . > report.txt`; to also capture progress and the disclosure, redirect both streams: `npx --yes solid-migration-assistant@latest --target . > report.txt 2>&1`. The analyzer does not edit the target or create persistent output there, and Codemod analytics are disabled. Codemod may persist workflow and task state in normal platform user-data directories outside the target; the assistant does not redirect or remove that runtime state. ## Supported detections @@ -63,11 +77,31 @@ A run with detections exits successfully. Complete opaque guidance strings are e Every finding links the immutable pinned [RC migration guide](https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md). Coverage follows the guide's complete quick rename / removal map. +## Transform + +The opt-in `transform` workflow relocates exactly five pure legacy Solid import subpaths and changes nothing else: + +- `solid-js/h` → `@solidjs/h` +- `solid-js/html` → `@solidjs/html` +- `solid-js/universal` → `@solidjs/universal` +- `solid-js/jsx-runtime` → `@solidjs/web/jsx-runtime` +- `solid-js/jsx-dev-runtime` → `@solidjs/web/jsx-dev-runtime` + +The transform covers static imports, re-exports, dynamic `import()`, and `require()` calls; preserves each reference's import form and quote style; and emits one per-edit report line (`file:line:column`, old → new, plus the migration-guide link). Every move is a pure package relocation with no removed, renamed, or behaviorally changed export, so the rewrite is safe without binding-level review. The workflow is idempotent and writes no report files or other artifacts in the target. It deliberately leaves `solid-js/web`, `solid-js/store`, already-migrated paths, and near-miss subpaths such as `solid-js/h-extra` and `vendor/solid-js/h` untouched. + +In this repository, run `pnpm transform` against the current directory, or invoke the Codemod CLI directly to target another directory: + +```sh +node ./node_modules/codemod/codemod --disable-analytics workflow run -w transform.yaml -t /path/to/a/solid-project --allow-dirty --no-interactive +``` + +After publication, select the `transform` workflow from the Codemod platform (it is registered with `default: false`). + ## Deliberate limits Current coverage is deliberately limited: the analyzer does not cover indirect calls, shadowed bindings, unsupported argument counts, re-exports, dynamic imports, `require`, TypeScript `import()` type expressions, configuration, dependencies, SSR, libraries, monorepos, or cross-file intent. Binding-sensitive call and JSX rules also exclude aliased and namespace bindings. No guidance—or a clean run—is not a readiness result and does not imply complete Solid 2 migration coverage. -Automated transforms are roadmap-only. This package exposes no transform command, workflow, test, or implementation. +The read-only `analyze` workflow remains detection-only. Broader automated transforms remain roadmap items beyond the five pure import-path relocations implemented by the `transform` workflow. ## Verify @@ -77,4 +111,4 @@ From the workspace root: pnpm verify ``` -Verification checks detection-only architecture, comprehensive rule boundaries, exact and repeatable terminal output, fixture immutability, TypeScript types, and workflow schema validity. +Verification checks the two-workflow architecture, comprehensive analysis and transformation rule boundaries, exact and repeatable terminal output, analyzer fixture immutability, transform idempotency, TypeScript types, and workflow schema validity. diff --git a/codemods/solid-migration-assistant/assets/dashboard/index.html b/codemods/solid-migration-assistant/assets/dashboard/index.html new file mode 100644 index 0000000..286cb3d --- /dev/null +++ b/codemods/solid-migration-assistant/assets/dashboard/index.html @@ -0,0 +1,18 @@ + + + + + + + Solid Migration Report + + + + + +
+ + diff --git a/codemods/solid-migration-assistant/codemod.yaml b/codemods/solid-migration-assistant/codemod.yaml index b91a8a3..95a458a 100644 --- a/codemods/solid-migration-assistant/codemod.yaml +++ b/codemods/solid-migration-assistant/codemod.yaml @@ -1,8 +1,8 @@ schema_version: "1.0" name: "solid-migration-assistant" -version: "0.2.1" -description: "Read-only Solid 1.9 to Solid 2 RC migration analyzer for project-owned JavaScript and TypeScript source" +version: "0.3.0" +description: "Solid 1.9 to Solid 2 RC migration assistant: read-only analyzer plus deterministic legacy import-path relocation for project-owned JavaScript and TypeScript source" author: "Solid Migration Assistant Contributors" license: "MIT" category: "migration" @@ -22,3 +22,6 @@ workflows: - name: analyze default: true path: workflow.yaml + - name: transform + default: false + path: transform.yaml diff --git a/codemods/solid-migration-assistant/dashboard/app.tsx b/codemods/solid-migration-assistant/dashboard/app.tsx new file mode 100644 index 0000000..7a35aa9 --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/app.tsx @@ -0,0 +1,240 @@ +import { Errored, For, Show, createSignal, onCleanup } from "solid-js"; +import type { JSX } from "@solidjs/web"; +import { createRouter, hashHistory, useHref, useParams } from "@solidjs/router"; +import type { + JsonValue, + ReportEnvelope, + RuleSliceDescriptor, +} from "../shared/report.ts"; +import { applyTheme, oppositeTheme, preferredTheme, type Theme } from "./theme.ts"; +import { CopyButton } from "./copy-button.tsx"; + +export function createDashboardRouter( + envelope: ReportEnvelope, + manifest: readonly RuleSliceDescriptor[], +) { + const groups = groupRulesByDomain(manifest); + + function IndexPage() { + return ( +
+
+

One immutable migration run

+

Rule reports

+

Open a registered pilot slice to review its rule-owned report.

+
+ 0} + fallback={

No pilot rule reports are registered.

} + > +
+ + {(group) => ( +
+

{group.domain}

+
+ + {(descriptor) => ( + + )} + +
+
+ )} +
+
+
+
+ ); + } + + function RulePage() { + const params = useParams(); + const descriptor = () => + manifest.find((entry) => entry.route === params.rule); + const payload = () => { + const entry = descriptor(); + return entry ? envelope.reports[entry.id] : undefined; + }; + const availablePayload = () => { + const report = payload(); + return report === undefined ? undefined : { report }; + }; + + return ( + }> + {(entry) => ( +
+

{entry().kind}

+

{entry().title}

+ This run has no payload for this rule.

} + > + {(available) => ( + ( +

+ This rule report could not be read: {errorMessage(error())} +

+ )} + > +
{entry().renderDetail(available().report, envelope.run)}
+
+ )} +
+
+ )} +
+ ); + } + + function NotFoundPage() { + return ( +
+

Page not found

+

The requested report page does not exist.

+ Return to all rule reports +
+ ); + } + + return createRouter({ + history: hashHistory(), + routes: [ + { path: "/", component: IndexPage }, + { path: "/rules/*rule", component: RulePage }, + { path: "*404", component: NotFoundPage }, + ], + }); +} + +function groupRulesByDomain(manifest: readonly RuleSliceDescriptor[]) { + const groups = new Map(); + for (const descriptor of manifest) { + const rules = groups.get(descriptor.domain) ?? []; + rules.push(descriptor); + groups.set(descriptor.domain, rules); + } + return [...groups].map(([domain, rules]) => ({ + domain, + rules, + slug: domain.toLocaleLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""), + })); +} + +function InternalLink(props: { readonly href: string; readonly children: JSX.Element }) { + const href = useHref(() => props.href); + return {props.children}; +} + +function RuleCard(props: { + readonly descriptor: RuleSliceDescriptor; + readonly payload: JsonValue | undefined; + readonly run: ReportEnvelope["run"]; +}) { + const availablePayload = () => + props.payload === undefined ? undefined : { report: props.payload }; + + return ( +
+

{props.descriptor.kind}

+

+ + {props.descriptor.title} + +

+ No payload in this run.

} + > + {(available) => ( + Invalid rule payload.

} + > +
+ {props.descriptor.renderSummary(available().report, props.run)} +
+
+ )} +
+
+ ); +} + +function UnknownRule(props: { readonly route: string }) { + return ( +
+

Unknown rule

+

+ {props.route || "(empty)"} is not a registered pilot rule. +

+ Return to all rule reports +
+ ); +} + +export function Shell(props: { + readonly children: JSX.Element; + readonly analyzedTargetRoot: string; +}) { + return ( + <> + +
{props.children}
+ + ); +} + +function ThemeSwitch() { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const [theme, setTheme] = createSignal(preferredTheme(media.matches)); + let manuallySelected = false; + const followSystemTheme = (event: MediaQueryListEvent) => { + if (!manuallySelected) setTheme(preferredTheme(event.matches)); + }; + media.addEventListener("change", followSystemTheme); + onCleanup(() => media.removeEventListener("change", followSystemTheme)); + + function toggleTheme() { + manuallySelected = true; + const next = oppositeTheme(theme()); + applyTheme(next); + setTheme(next); + } + + return ( + + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown report error"; +} diff --git a/codemods/solid-migration-assistant/dashboard/copy-button.tsx b/codemods/solid-migration-assistant/dashboard/copy-button.tsx new file mode 100644 index 0000000..0fdfda3 --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/copy-button.tsx @@ -0,0 +1,43 @@ +import { createSignal, onCleanup } from "solid-js"; + +export function CopyButton(props: { + readonly value: string; + readonly idleLabel: string; + readonly class?: string; +}) { + const [status, setStatus] = createSignal<"idle" | "copied" | "failed">("idle"); + let resetTimer: ReturnType | undefined; + onCleanup(() => { if (resetTimer !== undefined) clearTimeout(resetTimer); }); + + async function copyValue() { + try { + await copyText(props.value); + setStatus("copied"); + } catch { + setStatus("failed"); + } + if (resetTimer !== undefined) clearTimeout(resetTimer); + resetTimer = setTimeout(() => setStatus("idle"), 1_500); + } + + const label = () => status() === "copied" ? "Copied" : status() === "failed" ? "Copy failed" : props.idleLabel; + return ; +} + +async function copyText(value: string): Promise { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) throw new Error("Could not copy text."); + } +} diff --git a/codemods/solid-migration-assistant/dashboard/finding-model.ts b/codemods/solid-migration-assistant/dashboard/finding-model.ts new file mode 100644 index 0000000..793cf52 --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/finding-model.ts @@ -0,0 +1,70 @@ +export const FINDINGS_PER_PAGE = 100; + +export function normalizeRelativeFilename(filename: string): string { + return filename.replaceAll("\\", "/").replace(/^\.\/+/, ""); +} + +export function formatFindingLocation(filename: string, line: number, column: number): string { + return `${normalizeRelativeFilename(filename)}:${line}:${column}`; +} + +export type EditorTarget = { + readonly analyzedTargetRoot: string; + readonly filename: string; + readonly line: number; + readonly column: number; +}; + +export type EditorAction = { + readonly id: string; + readonly label: string; + readonly href: string; +}; + +export function createEditorActions(target: EditorTarget): readonly EditorAction[] { + return [{ id: "vscode", label: "Visual Studio Code", href: createVsCodeFileUri(target) }]; +} + +export function createVsCodeFileUri(target: EditorTarget): string { + const root = target.analyzedTargetRoot.replaceAll("\\", "/").replace(/\/$/, ""); + const relative = normalizeRelativeFilename(target.filename).replace(/^\//, ""); + const absolutePath = `${root}/${relative}`; + const encodedPath = absolutePath + .split("/") + .map((segment) => encodeURIComponent(segment).replaceAll("%3A", ":")) + .join("/"); + return `vscode://file/${encodedPath}:${target.line}:${target.column}`; +} + +export type FilterableFinding = { + readonly filename: string; +}; + +export function filterFindings( + findings: readonly TFinding[], + filenameFilter: string, +): readonly TFinding[] { + const query = filenameFilter.trim().toLocaleLowerCase(); + if (!query) return findings; + return findings.filter((finding) => + finding.filename.toLocaleLowerCase().includes(query), + ); +} + +export function paginateFindings( + findings: readonly TFinding[], + requestedPage: number, + pageSize = FINDINGS_PER_PAGE, +): { + readonly items: readonly TFinding[]; + readonly page: number; + readonly pageCount: number; +} { + if (!Number.isSafeInteger(pageSize) || pageSize < 1) { + throw new Error("Finding page size must be a positive integer."); + } + const pageCount = Math.max(1, Math.ceil(findings.length / pageSize)); + const page = Math.min(Math.max(1, Math.trunc(requestedPage) || 1), pageCount); + const start = (page - 1) * pageSize; + return { items: findings.slice(start, start + pageSize), page, pageCount }; +} diff --git a/codemods/solid-migration-assistant/dashboard/finding-ui.tsx b/codemods/solid-migration-assistant/dashboard/finding-ui.tsx new file mode 100644 index 0000000..2b4ddbf --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/finding-ui.tsx @@ -0,0 +1,208 @@ +import type { JSX } from "@solidjs/web"; +import type { SourceSnippet } from "../shared/report.ts"; +import { useLocation } from "@solidjs/router"; +import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; +import { + createEditorActions, + filterFindings, + formatFindingLocation, + paginateFindings, + type EditorTarget, +} from "./finding-model.ts"; +import { CopyButton } from "./copy-button.tsx"; + +export type FindingView = { + readonly filename: string; + readonly location: string; + readonly label: JSX.Element; + readonly snippet: SourceSnippet; + readonly details: JSX.Element; + readonly editorTarget: EditorTarget; +}; + +export function GuidanceSections(props: { + readonly summary: string; + readonly reason: string; + readonly nextSteps: readonly string[]; + readonly cautions: readonly string[]; + readonly validation: readonly string[]; + readonly officialGuideUrl: string; +}) { + return ( +
+

{props.summary}

+
+

Why this was flagged

+

{props.reason}

+
+
+

Next steps

+
    {(step) =>
  1. {step}
  2. }
+
+ 0}> +
+

Caution Stop conditions

+
    {(caution) =>
  • {caution}
  • }
+
+
+ 0}> +
+

Validate

+
    {(item) =>
  • {item}
  • }
+
+
+

Open the official Solid 2 migration guide

+
+ ); +} + +export function RuleFindings(props: { readonly findings: readonly FindingView[] }) { + const location = useLocation(); + const [requestedPage, setRequestedPage] = createSignal(1); + const filenameFilter = () => + typeof location.query.filename === "string" ? location.query.filename : ""; + let previousFilter = filenameFilter(); + + createEffect( + filenameFilter, + (nextFilter) => { + if (nextFilter !== previousFilter) { + previousFilter = nextFilter; + setRequestedPage(1); + } + }, + ); + + const matches = createMemo(() => filterFindings(props.findings, filenameFilter())); + const resultPage = createMemo(() => paginateFindings(matches(), requestedPage())); + + function updateFilter(value: string) { + const search = new URLSearchParams(location.search); + if (value) search.set("filename", value); + else search.delete("filename"); + const query = search.toString(); + window.location.replace(`#${location.pathname}${query ? `?${query}` : ""}`); + } + + return ( +
+ +

+ {matches().length} of {props.findings.length} findings match +

+ 0} + fallback={( +

+ {props.findings.length === 0 + ? "This rule found no migration sites in this run." + : "No findings match this filename filter."} +

+ )} + > +
    + + {(finding) => ( +
  1. + +
  2. + )} +
    +
+ 1}> + + +
+
+ ); +} + +function SourceCode(props: { readonly snippet: SourceSnippet }) { + const lines = () => props.snippet.text.split("\n"); + return ( +
{(line, index) => {
+      const lineNumber = () => props.snippet.startLine + index();
+      const matched = () => lineNumber() >= props.snippet.matchStartLine && lineNumber() <= props.snippet.matchEndLine;
+      return (
+        
+          
+          {matched() ? "Matched " : ""}Line {lineNumber()}: 
+          {line || " "}
+        
+      );
+    }}
+ ); +} + +function FindingDisclosure(props: FindingView) { + return ( +
+
+
+ {props.location} + {props.label} +
+ +
+
+ Source and guidance +

Source context

+ + {props.details} +
+
+ ); +} + +function EditorActionMenu(props: { readonly target: EditorTarget }) { + const actions = createEditorActions(props.target); + const primaryAction = actions[0]!; + return ( +
+ Open in VS Code +
+ + +
+
+ ); +} diff --git a/codemods/solid-migration-assistant/dashboard/index.html b/codemods/solid-migration-assistant/dashboard/index.html new file mode 100644 index 0000000..1cec4ba --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/index.html @@ -0,0 +1,14 @@ + + + + + + + Solid Migration Report + + + +
+ + + diff --git a/codemods/solid-migration-assistant/dashboard/manifest.ts b/codemods/solid-migration-assistant/dashboard/manifest.ts new file mode 100644 index 0000000..063cc86 --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/manifest.ts @@ -0,0 +1,7 @@ +import { createRuleManifest } from "../shared/report.ts"; +import { componentRenamesSlice } from "../rules/analysis/jsx/component-renames/ui.tsx"; +import { createEffectSlice } from "../rules/analysis/reactivity/create-effect/ui.tsx"; +import { legacySubpathRelocationSlice } from "../rules/transformations/imports/legacy-subpath-relocation/ui.tsx"; +import { webImportSlice } from "../rules/analysis/imports/web-import/ui.tsx"; + +export const ruleManifest = createRuleManifest([webImportSlice, componentRenamesSlice, createEffectSlice, legacySubpathRelocationSlice]); diff --git a/codemods/solid-migration-assistant/dashboard/src.tsx b/codemods/solid-migration-assistant/dashboard/src.tsx new file mode 100644 index 0000000..095be2c --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/src.tsx @@ -0,0 +1,28 @@ +import { render } from "@solidjs/web"; +import { createDashboardRouter, Shell } from "./app.tsx"; +import { ruleManifest } from "./manifest.ts"; +import { readEmbeddedReport } from "../shared/report.ts"; +import "./styles.css"; + +const mount = document.getElementById("app"); +if (!mount) throw new Error("Dashboard mount element was not found."); + +try { + const report = readEmbeddedReport(document); + const Router = createDashboardRouter(report, ruleManifest); + render( + () => {(props) => {props.children}}, + mount, + ); +} catch (error) { + const message = error instanceof Error ? error.message : "Unknown report error"; + render( + () => ( +
+

Report unavailable

+

{message}

+
+ ), + mount, + ); +} diff --git a/codemods/solid-migration-assistant/dashboard/styles.css b/codemods/solid-migration-assistant/dashboard/styles.css new file mode 100644 index 0000000..a7b49af --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/styles.css @@ -0,0 +1,173 @@ +:root, +:root[data-theme="light"] { + color-scheme: light; + --background: #f5f8f8; + --surface: #ffffff; + --surface-strong: #edf3f4; + --text: #172126; + --text-soft: #4d5e65; + --border: #c8d5d9; + --border-strong: #91a6ad; + --link: #00698f; + --link-hover: #004b68; + --accent: #167447; + --accent-soft: #e4f5eb; + --accent-text: #155c3b; + --code: #153f50; + --code-background: #edf2f3; + --danger: #9d2b24; + --danger-border: #d29a95; + --danger-background: #fff0ee; + --focus: #007ba5; + --match-background: #dff4e8; + --match-border: #167447; + --line-number: #63777f; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color-scheme: dark; + --background: #0b1013; + --surface: #121a1f; + --surface-strong: #10171b; + --text: #e8f0f2; + --text-soft: #b1c0c6; + --border: #26343b; + --border-strong: #3a4b53; + --link: #75d7ff; + --link-hover: #b7ebff; + --accent: #82e0a5; + --accent-soft: #13251c; + --accent-text: #a4edbf; + --code: #c8e7f3; + --code-background: #0a0f12; + --danger: #ffb4ab; + --danger-border: #75433f; + --danger-background: #281817; + --focus: #75d7ff; + --match-background: #173326; + --match-border: #82e0a5; + --line-number: #82949b; + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --background: #0b1013; + --surface: #121a1f; + --surface-strong: #10171b; + --text: #e8f0f2; + --text-soft: #b1c0c6; + --border: #26343b; + --border-strong: #3a4b53; + --link: #75d7ff; + --link-hover: #b7ebff; + --accent: #82e0a5; + --accent-soft: #13251c; + --accent-text: #a4edbf; + --code: #c8e7f3; + --code-background: #0a0f12; + --danger: #ffb4ab; + --danger-border: #75433f; + --danger-background: #281817; + --focus: #75d7ff; + --match-background: #173326; + --match-border: #82e0a5; + --line-number: #82949b; +} +:root { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: var(--text); + background: var(--background); + font-synthesis: none; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--background); } +a { color: var(--link); text-underline-offset: 0.18em; } +a:hover { color: var(--link-hover); } +button, input { font: inherit; } +button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { + outline: 3px solid color-mix(in srgb, var(--focus) 55%, transparent); + outline-offset: 2px; +} +.site-header { + display: flex; align-items: flex-start; justify-content: space-between; gap: 2rem; + padding: 1.5rem clamp(1.25rem, 4vw, 4rem); border-bottom: 1px solid var(--border); + background: var(--surface-strong); +} +.site-header h1 { margin: 0.15rem 0 0; font-size: clamp(1.35rem, 3vw, 1.8rem); } +.site-header h1 a { color: inherit; text-decoration: none; } +.report-identity { min-width: 0; } +.header-actions { display: flex; align-items: center; gap: 0.75rem; } +.target-root { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.35rem 0.65rem; margin-top: 0.7rem; font-size: 0.78rem; } +.target-root > span { color: var(--text-soft); font-weight: 700; } +.target-root code { min-width: 0; overflow-wrap: anywhere; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.copy-root, .copy-location { border: 0; padding: 0; background: transparent; color: var(--link); text-decoration: underline; text-underline-offset: 0.18em; cursor: pointer; } +.copy-root:hover, .copy-location:hover { color: var(--link-hover); } +.theme-switch { + display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border-strong); + border-radius: 999px; padding: 0.38rem 0.7rem; background: var(--surface); color: var(--text); + cursor: pointer; +} +.theme-switch:hover { border-color: var(--link); } +.site-main { width: min(70rem, 100%); margin: 0 auto; padding: 3rem clamp(1.25rem, 4vw, 3rem) 5rem; } +h2 { margin: 0.25rem 0 0.6rem; font-size: clamp(1.8rem, 5vw, 2.7rem); } +h3 { margin: 0.15rem 0 1rem; } +p { color: var(--text-soft); line-height: 1.6; } +.eyebrow { color: var(--accent); font-size: 0.76rem; font-weight: 750; letter-spacing: 0.12em; text-transform: uppercase; margin: 0; } +.run-badge { border: 1px solid var(--border-strong); color: var(--accent-text); background: var(--accent-soft); border-radius: 999px; padding: 0.35rem 0.7rem; white-space: nowrap; } +.page-heading { margin-bottom: 2rem; } +.domain-groups { display: grid; gap: 2.25rem; } +.domain-group > h3 { margin-bottom: 0.9rem; font-size: 1.25rem; } +.report-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1rem; } +.report-card { border: 1px solid var(--border); border-radius: 0.8rem; background: var(--surface); padding: 1.25rem; } +.rule-summary, .rule-detail { color: var(--text); } +.empty-state, .error-state, .zero-state { border: 1px dashed var(--border-strong); border-radius: 0.7rem; padding: 1rem; } +.error-state { color: var(--danger); border-color: var(--danger-border); background: var(--danger-background); } +.compact { margin-bottom: 0; padding: 0.7rem; } +code { color: var(--code); } +.rule-findings, .finding-entry, .finding-disclosure { min-width: 0; } +.finding-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; padding-left: 1.25rem; } +.finding-list > li { min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: 0.7rem; background: var(--surface); } +pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0.8rem; border-radius: 0.55rem; background: var(--code-background); color: var(--text-soft); } +pre code { color: inherit; } +.source-snippet { width: 100%; max-width: 100%; min-width: 0; color: var(--text); overflow-x: auto; overflow-y: hidden; overflow-wrap: normal; white-space: pre; padding: 0.55rem 0; } +.source-snippet:focus-visible { outline-offset: 3px; } +.source-line { display: grid; grid-template-columns: 4ch max-content; column-gap: 1rem; min-width: 100%; width: max-content; padding: 0.14rem 0.8rem; border-left: 3px solid transparent; } +.source-line.matched { background: var(--match-background); border-left-color: var(--match-border); } +.line-number { color: var(--line-number); text-align: right; user-select: none; } +.line-content { white-space: pre; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.filename-filter { display: grid; gap: 0.45rem; max-width: 32rem; color: var(--text); font-weight: 650; } +.filename-filter input { width: 100%; border: 1px solid var(--border-strong); border-radius: 0.55rem; background: var(--code-background); color: var(--text); padding: 0.75rem 0.85rem; } +.result-count { margin: 0.6rem 0 1.2rem; font-size: 0.9rem; } +.finding-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.finding-heading > div { display: grid; gap: 0.35rem; min-width: 0; } +.finding-heading span { color: var(--text-soft); } +.finding-actions { display: flex; align-items: center; gap: 0.5rem; flex: none; } +.open-editor { border: 1px solid var(--border-strong); border-radius: 0.45rem; padding: 0.38rem 0.6rem; background: var(--accent-soft); color: var(--accent-text); text-decoration: none; white-space: nowrap; } +.open-editor:hover { border-color: var(--accent); color: var(--accent-text); } +.overflow-actions { position: relative; } +.overflow-actions > summary { display: grid; place-items: center; min-width: 2.1rem; min-height: 2.1rem; border: 1px solid var(--border-strong); border-radius: 0.45rem; color: var(--link); cursor: pointer; list-style: none; } +.overflow-actions > summary::-webkit-details-marker { display: none; } +.overflow-actions ul { position: absolute; right: 0; z-index: 2; min-width: 10rem; display: grid; gap: 0.45rem; margin: 0.35rem 0 0; padding: 0.75rem 0.75rem 0.75rem 1.8rem; border: 1px solid var(--border); border-radius: 0.55rem; background: var(--surface); box-shadow: 0 0.5rem 1.2rem color-mix(in srgb, var(--text) 16%, transparent); } +.finding-disclosure { margin-top: 1rem; } +.guidance-sections { margin-top: 1rem; } +.guidance-sections section { margin-top: 1.25rem; } +.guidance-sections h4 { margin: 0 0 0.45rem; } +.guidance-sections ol, .guidance-sections ul { display: grid; gap: 0.45rem; color: var(--text-soft); line-height: 1.5; } +.finding-summary { color: var(--text); font-weight: 700; } +.caution-section { border-left: 3px solid var(--danger-border); padding-left: 0.9rem; } +.section-badge { display: inline-block; margin-right: 0.35rem; padding: 0.15rem 0.4rem; border-radius: 0.35rem; background: var(--danger-background); color: var(--danger); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; } +.pagination button { margin-top: 1rem; border: 1px solid var(--border-strong); border-radius: 0.45rem; background: var(--accent-soft); color: var(--accent-text); padding: 0.5rem 0.7rem; cursor: pointer; } +.pagination button:hover:not(:disabled) { border-color: var(--accent); } +.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-top: 1.5rem; } +.pagination button { margin: 0; } +.pagination button:disabled { cursor: not-allowed; opacity: 0.45; } +@media (max-width: 42rem) { + .site-header { flex-direction: column; gap: 1rem; } + .header-actions { width: 100%; justify-content: space-between; } + .finding-heading { flex-direction: column; } + .finding-actions { width: 100%; flex-wrap: wrap; } + .target-root { grid-template-columns: 1fr auto; } + .target-root > span { grid-column: 1 / -1; } + .target-root code { grid-column: 1; } +} diff --git a/codemods/solid-migration-assistant/dashboard/theme.ts b/codemods/solid-migration-assistant/dashboard/theme.ts new file mode 100644 index 0000000..a9fa921 --- /dev/null +++ b/codemods/solid-migration-assistant/dashboard/theme.ts @@ -0,0 +1,13 @@ +export type Theme = "light" | "dark"; + +export function preferredTheme(prefersDark: boolean): Theme { + return prefersDark ? "dark" : "light"; +} + +export function oppositeTheme(theme: Theme): Theme { + return theme === "dark" ? "light" : "dark"; +} + +export function applyTheme(theme: Theme, root: HTMLElement = document.documentElement): void { + root.dataset.theme = theme; +} diff --git a/codemods/solid-migration-assistant/package.json b/codemods/solid-migration-assistant/package.json index 05955b2..f5f617e 100644 --- a/codemods/solid-migration-assistant/package.json +++ b/codemods/solid-migration-assistant/package.json @@ -1,7 +1,7 @@ { "name": "solid-migration-assistant", - "version": "0.2.1", - "description": "Read-only Solid 1.9 to Solid 2 RC migration analyzer for project-owned JavaScript and TypeScript source", + "version": "0.3.0", + "description": "Solid 1.9 to Solid 2 RC migration assistant: read-only analyzer plus deterministic legacy import-path relocation for project-owned JavaScript and TypeScript source", "license": "MIT", "author": "Solid Migration Assistant Contributors", "type": "module", @@ -11,39 +11,50 @@ "files": [ "LICENSE", "README.md", + "assets/dashboard/index.html", "bin/solid-migration-assistant.mjs", - "rules/imports/beta32-subpaths/beta32-subpaths.ts", - "rules/imports/web-import/web-import.ts", - "rules/jsx/class-list/class-list.ts", - "rules/jsx/component-renames/component-renames.ts", - "rules/jsx/context-provider/context-provider.ts", - "rules/jsx/dom-attr-namespaces/dom-attr-namespaces.ts", - "rules/jsx/dom-event-namespaces/dom-event-namespaces.ts", - "rules/jsx/dom-use-directive/dom-use-directive.ts", - "rules/lifecycle/on-cleanup/on-cleanup.ts", - "rules/lifecycle/on-mount/on-mount.ts", - "rules/props/merge-props/merge-props.ts", - "rules/props/split-props/split-props.ts", - "rules/reactivity/batch/batch.ts", - "rules/reactivity/create-computed/create-computed.ts", - "rules/reactivity/create-dynamic/create-dynamic.ts", - "rules/reactivity/create-effect/create-effect.ts", - "rules/reactivity/create-memo/create-memo.ts", - "rules/reactivity/create-resource/create-resource.ts", - "rules/reactivity/create-selector/create-selector.ts", - "rules/reactivity/error-handling/error-handling.ts", - "rules/reactivity/from-observable/from-observable.ts", - "rules/reactivity/index-array/index-array.ts", - "rules/reactivity/on-helper/on-helper.ts", - "rules/reactivity/transition-apis/transition-apis.ts", - "rules/reactivity/utility-renames/utility-renames.ts", - "rules/store/mutable/mutable.ts", - "rules/store/produce/produce.ts", - "rules/store/unwrap/unwrap.ts", + "rules/analysis/imports/beta32-subpaths/beta32-subpaths.ts", + "rules/analysis/imports/web-import/web-import.ts", + "rules/analysis/imports/web-import/report.ts", + "rules/analysis/jsx/class-list/class-list.ts", + "rules/analysis/jsx/component-renames/component-renames.ts", + "rules/analysis/jsx/component-renames/report.ts", + "rules/analysis/jsx/context-provider/context-provider.ts", + "rules/analysis/jsx/dom-attr-namespaces/dom-attr-namespaces.ts", + "rules/analysis/jsx/dom-event-namespaces/dom-event-namespaces.ts", + "rules/analysis/jsx/dom-use-directive/dom-use-directive.ts", + "rules/analysis/lifecycle/on-cleanup/on-cleanup.ts", + "rules/analysis/lifecycle/on-mount/on-mount.ts", + "rules/analysis/props/merge-props/merge-props.ts", + "rules/analysis/props/split-props/split-props.ts", + "rules/analysis/reactivity/batch/batch.ts", + "rules/analysis/reactivity/create-computed/create-computed.ts", + "rules/analysis/reactivity/create-dynamic/create-dynamic.ts", + "rules/analysis/reactivity/create-effect/create-effect.ts", + "rules/analysis/reactivity/create-effect/report.ts", + "rules/analysis/reactivity/create-memo/create-memo.ts", + "rules/analysis/reactivity/create-resource/create-resource.ts", + "rules/analysis/reactivity/create-selector/create-selector.ts", + "rules/analysis/reactivity/error-handling/error-handling.ts", + "rules/analysis/reactivity/from-observable/from-observable.ts", + "rules/analysis/reactivity/index-array/index-array.ts", + "rules/analysis/reactivity/on-helper/on-helper.ts", + "rules/analysis/reactivity/transition-apis/transition-apis.ts", + "rules/analysis/reactivity/utility-renames/utility-renames.ts", + "rules/analysis/store/mutable/mutable.ts", + "rules/analysis/store/produce/produce.ts", + "rules/analysis/store/unwrap/unwrap.ts", + "rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.ts", + "rules/transformations/imports/legacy-subpath-relocation/report.ts", "scripts/analyze.ts", - "scripts/emit.ts", + "scripts/emit-report.ts", + "scripts/transform.ts", "shared/analysis.ts", + "shared/report.ts", + "shared/report-artifact.mjs", "shared/run-workflow.mjs", + "shared/transform.ts", + "transform.yaml", "workflow.yaml" ], "repository": { @@ -75,20 +86,32 @@ "packageManager": "pnpm@11.20.0", "scripts": { "analyze": "node ./bin/solid-migration-assistant.mjs", - "test": "pnpm test:architecture && pnpm test:package && pnpm test:rules && pnpm test:workflow", + "test": "pnpm test:architecture && pnpm test:report && pnpm test:package && pnpm test:rules && pnpm test:transform-rules && pnpm test:workflow && pnpm test:transform", "test:architecture": "node --test ./tests/architecture.test.mjs", "test:package": "node --test ./tests/cli.test.mjs ./tests/packaging.test.mjs", "test:workflow": "node ./tests/workflow.test.mjs", - "check-types": "tsc --noEmit", - "validate": "node ./node_modules/codemod/codemod --disable-analytics workflow validate -w workflow.yaml", + "check-types": "tsc --noEmit && tsc --project tsconfig.dashboard.json", + "validate": "node ./node_modules/codemod/codemod --disable-analytics workflow validate -w workflow.yaml && node ./node_modules/codemod/codemod --disable-analytics workflow validate -w transform.yaml", "verify": "pnpm test && pnpm check-types && pnpm validate", - "test:rules": "node ./tests/rules.test.mjs" + "test:rules": "node ./tests/rules.test.mjs", + "test:transform-rules": "node ./tests/transform-rules.test.mjs", + "transform": "node ./node_modules/codemod/codemod --disable-analytics workflow run -w transform.yaml --allow-dirty --no-interactive", + "test:transform": "node ./tests/transform.test.mjs", + "build:dashboard": "vite build --config vite.dashboard.config.ts", + "test:report": "node --test ./tests/report.test.mjs" }, "dependencies": { - "codemod": "1.12.13" + "@solidjs/router": "2.0.0-next.16", + "@solidjs/web": "2.0.0-rc.0", + "codemod": "1.12.13", + "solid-js": "2.0.0-rc.0" }, "devDependencies": { "@codemod.com/jssg-types": "1.6.2", - "typescript": "6.0.3" + "@solidjs/vite-plugin": "3.0.0-next.28", + "@types/node": "24.10.1", + "typescript": "6.0.3", + "vite": "8.2.1", + "vite-plugin-singlefile": "2.3.3" } } diff --git a/codemods/solid-migration-assistant/rules/imports/beta32-subpaths/beta32-subpaths.test.ts b/codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/beta32-subpaths.test.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/imports/beta32-subpaths/beta32-subpaths.test.ts rename to codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/beta32-subpaths.test.ts index 9758c79..7c1bf63 100644 --- a/codemods/solid-migration-assistant/rules/imports/beta32-subpaths/beta32-subpaths.test.ts +++ b/codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/beta32-subpaths.test.ts @@ -5,7 +5,7 @@ import { analyzeBeta32SubpathImports } from "./beta32-subpaths.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#imports-where-things-live-now"; const STORE_STOP_CONDITION = - "Stop: do not blindly rewrite this source if the import includes removed or renamed beta.32 helpers such as unwrap, produce, createMutable, or modifyMutable. Migrate those bindings and call sites first, then move supported store imports to solid-js."; + "Stop: do not blindly rewrite this source if the import includes removed or renamed helpers such as unwrap, produce, createMutable, or modifyMutable. Migrate those bindings and call sites first, then move supported store imports to solid-js."; const EXPECTED_SITES = [ // Static imports { location: "1:29", legacy: "solid-js/store", replacement: "solid-js", form: "static import" }, diff --git a/codemods/solid-migration-assistant/rules/imports/beta32-subpaths/beta32-subpaths.ts b/codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/beta32-subpaths.ts similarity index 88% rename from codemods/solid-migration-assistant/rules/imports/beta32-subpaths/beta32-subpaths.ts rename to codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/beta32-subpaths.ts index 482b4f2..9f65e25 100644 --- a/codemods/solid-migration-assistant/rules/imports/beta32-subpaths/beta32-subpaths.ts +++ b/codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/beta32-subpaths.ts @@ -1,7 +1,7 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findModuleReferences } from "../../../shared/analysis.ts"; -import type { ModuleReference } from "../../../shared/analysis.ts"; +import { findModuleReferences } from "../../../../shared/analysis.ts"; +import type { ModuleReference } from "../../../../shared/analysis.ts"; const BETA32_SUBPATH_REPLACEMENTS: Readonly> = { "solid-js/store": "solid-js", @@ -14,7 +14,7 @@ const BETA32_SUBPATH_REPLACEMENTS: Readonly> = { const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#imports-where-things-live-now"; const STORE_STOP_CONDITION = - "Stop: do not blindly rewrite this source if the import includes removed or renamed beta.32 helpers such as unwrap, produce, createMutable, or modifyMutable. Migrate those bindings and call sites first, then move supported store imports to solid-js."; + "Stop: do not blindly rewrite this source if the import includes removed or renamed helpers such as unwrap, produce, createMutable, or modifyMutable. Migrate those bindings and call sites first, then move supported store imports to solid-js."; const FORM_LABELS: Record = { import: "static import", diff --git a/codemods/solid-migration-assistant/rules/imports/beta32-subpaths/static-imports.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/static-imports.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/imports/beta32-subpaths/static-imports.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/imports/beta32-subpaths/static-imports.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/analysis/imports/web-import/report.ts b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/report.ts new file mode 100644 index 0000000..b14cef1 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/report.ts @@ -0,0 +1,36 @@ +import { defineRuleReportAggregator, type SourceSnippet } from "../../../../shared/report.ts"; +export const WEB_IMPORT_RULE_ID = "analysis/imports/web-import"; +export const WEB_IMPORT_RULE_ROUTE = "imports/web-import"; +export type WebImportForm = "import" | "re-export" | "dynamic-import" | "require"; +export type WebImportFinding = { + readonly filename: string; + readonly line: number; + readonly column: number; + readonly form: WebImportForm; + readonly summary: string; + readonly reason: string; + readonly nextSteps: readonly string[]; + readonly cautions: readonly string[]; + readonly validation: readonly string[]; + readonly officialGuideUrl: string; + readonly snippet: SourceSnippet; +}; +export type WebImportReport = { readonly findings: readonly WebImportFinding[] }; + +export function formatWebImportGuidance(finding: WebImportFinding): string { + return `${finding.filename}:${finding.line}:${finding.column} ${finding.summary} +Why: ${finding.reason} +Guidance: ${[...finding.nextSteps, ...finding.cautions, ...finding.validation].join(" ")} Official migration guide: ${finding.officialGuideUrl}`; +} + +export const webImportReportAggregator = defineRuleReportAggregator({ + id: WEB_IMPORT_RULE_ID, + emptyReport: () => ({ findings: [] }), + merge: (projectReport, nextReport) => ({ + findings: [...projectReport.findings, ...nextReport.findings].sort(compareFindings), + }), +}); +function compareFindings(left: WebImportFinding, right: WebImportFinding): number { + const filenameOrder = left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0; + return filenameOrder || left.line - right.line || left.column - right.column; +} diff --git a/codemods/solid-migration-assistant/rules/imports/web-import/static-imports.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/static-imports.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/imports/web-import/static-imports.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/imports/web-import/static-imports.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/analysis/imports/web-import/ui.tsx b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/ui.tsx new file mode 100644 index 0000000..a2b4a2a --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/ui.tsx @@ -0,0 +1,41 @@ +import { GuidanceSections, RuleFindings } from "../../../../dashboard/finding-ui.tsx"; +import { formatFindingLocation } from "../../../../dashboard/finding-model.ts"; +import { defineRuleSlice } from "../../../../shared/report.ts"; +import { + WEB_IMPORT_RULE_ID, + WEB_IMPORT_RULE_ROUTE, + type WebImportReport, +} from "./report.ts"; + +export const webImportSlice = defineRuleSlice({ + id: WEB_IMPORT_RULE_ID, + route: WEB_IMPORT_RULE_ROUTE, + title: "Web renderer imports", + domain: "Imports", + kind: "analysis", + Summary: (props) => ( +

{props.report.findings.length} legacy web import sites

+ ), + Detail: (props) => ( + ({ + filename: finding.filename, + location: formatFindingLocation(finding.filename, finding.line, finding.column), + label: finding.form, + snippet: finding.snippet, + editorTarget: { + analyzedTargetRoot: props.run.analyzedTargetRoot, + filename: finding.filename, + line: finding.line, + column: finding.column, + }, + details: , + }))} /> + ), +}); diff --git a/codemods/solid-migration-assistant/rules/imports/web-import/web-import.test.ts b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/web-import.test.ts similarity index 68% rename from codemods/solid-migration-assistant/rules/imports/web-import/web-import.test.ts rename to codemods/solid-migration-assistant/rules/analysis/imports/web-import/web-import.test.ts index b85145b..56bdafb 100644 --- a/codemods/solid-migration-assistant/rules/imports/web-import/web-import.test.ts +++ b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/web-import.test.ts @@ -1,6 +1,7 @@ import type { Codemod } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; import { analyzeWebImport } from "./web-import.ts"; +import { formatWebImportGuidance } from "./report.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#imports-where-things-live-now"; @@ -27,7 +28,7 @@ const EXPECTED_SITES = [ const testWebImportRule: Codemod = async (root) => { const filename = root.relativeFilename().replaceAll("\\", "/"); - const guidance = analyzeWebImport(root.root(), { filename }); + const { guidance, report } = analyzeWebImport(root.root(), { filename }); const expected = EXPECTED_SITES.map( ({ location, form }) => { const formLabel = form; @@ -65,6 +66,24 @@ Guidance:${formGuidance} Make and validate that edit yourself; this analyzer nev } } + + for (const [index, finding] of report.findings.entries()) { + if ("guidance" in finding || !finding.summary || !finding.reason || finding.nextSteps.length === 0 || !finding.officialGuideUrl) { + throw new Error("web import report must expose structured guidance fields only"); + } + if (formatWebImportGuidance(finding) !== guidance[index]) throw new Error("web import terminal guidance drifted from its report contract"); + if (finding.snippet.matchStartLine !== finding.line || finding.snippet.matchEndLine < finding.snippet.matchStartLine || finding.snippet.matchEndLine > finding.snippet.endLine) { + throw new Error("snippet must carry the AST match line range"); + } + if (!finding.snippet.text.includes("solid-js") || !finding.snippet.text.includes("web")) throw new Error("web import snippet must contain the matched source"); + if (finding.snippet.startLine !== Math.max(1, finding.line - 1)) { + throw new Error(`snippet must start one complete line before ${finding.line}`); + } + if (finding.snippet.endLine < finding.line || finding.snippet.text.split("\n").length !== finding.snippet.endLine - finding.snippet.startLine + 1) { + throw new Error(`snippet must include the full match and complete line bounds at ${finding.line}`); + } + } + return null; }; diff --git a/codemods/solid-migration-assistant/rules/analysis/imports/web-import/web-import.ts b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/web-import.ts new file mode 100644 index 0000000..0d199f4 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/imports/web-import/web-import.ts @@ -0,0 +1,52 @@ +import type { SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { findModuleReferences, sourceSnippet } from "../../../../shared/analysis.ts"; +import type { ModuleReference } from "../../../../shared/analysis.ts"; +import type { AnalysisRuleResult } from "../../../../shared/report.ts"; +import { formatWebImportGuidance, type WebImportFinding, type WebImportReport } from "./report.ts"; + +const LEGACY_WEB_MODULE = "solid-js/web"; +const MIGRATION_GUIDE = + "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#imports-where-things-live-now"; + +const FORM_LABELS: Record = { + import: "static import", + "re-export": "re-export", + "dynamic-import": "dynamic import()", + require: "require() call", +}; + +const FORM_GUIDANCE: Record = { + import: "Change only this static import's module source to @solidjs/web and preserve its import form and quote style.", + "re-export": "Change only this re-export's module source to @solidjs/web and preserve its export form and quote style.", + "dynamic-import": "Change only this dynamic import's module source to @solidjs/web and preserve its quote style.", + require: "Change only this require call's module source to @solidjs/web and preserve its quote style.", +}; + +export function analyzeWebImport( + rootNode: SgNode, + context: { filename: string }, +): AnalysisRuleResult { + const findings: WebImportFinding[] = findModuleReferences(rootNode) + .filter((ref) => ref.moduleName === LEGACY_WEB_MODULE) + .map(({ source, form }) => { + const start = source.range().start; + return { + filename: context.filename, + line: start.line + 1, + column: start.column + 1, + form, + summary: `Move this Solid web renderer ${FORM_LABELS[form]}.`, + reason: "Solid 2 publishes the web renderer from @solidjs/web instead of the solid-js/web subpath.", + nextSteps: [ + FORM_GUIDANCE[form], + "Make and validate that edit yourself; this analyzer never edits or runs the target project.", + ], + cautions: [], + validation: [], + officialGuideUrl: MIGRATION_GUIDE, + snippet: sourceSnippet(source), + }; + }); + return { guidance: findings.map(formatWebImportGuidance), report: { findings } }; +} diff --git a/codemods/solid-migration-assistant/rules/jsx/class-list/class-list.test.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/class-list/class-list.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/class-list/class-list.test.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/class-list/class-list.test.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/class-list/class-list.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/class-list/class-list.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/class-list/class-list.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/class-list/class-list.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/class-list/normal.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/class-list/normal.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/class-list/normal.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/jsx/class-list/normal.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/jsx/component-renames/component-renames.test.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/component-renames.test.ts similarity index 84% rename from codemods/solid-migration-assistant/rules/jsx/component-renames/component-renames.test.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/component-renames.test.ts index 1ef0742..432edd8 100644 --- a/codemods/solid-migration-assistant/rules/jsx/component-renames/component-renames.test.ts +++ b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/component-renames.test.ts @@ -1,6 +1,7 @@ import type { Codemod } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; import { analyzeJsxComponentRenames } from "./component-renames.ts"; +import { formatComponentRenameGuidance } from "./report.ts"; const SUSPENSE_BOUNDARY_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#suspense--errorboundary--loading--errored"; @@ -11,7 +12,7 @@ const SUSPENSE_LIST_GUIDE = const testJsxComponentRenames: Codemod = async (root) => { const filename = root.relativeFilename().replaceAll("\\", "/"); - const guidance = analyzeJsxComponentRenames(root.root(), { + const { guidance, report } = analyzeJsxComponentRenames(root.root(), { filename: "ignored-context-filename.tsx", }); const expected = [ @@ -47,6 +48,24 @@ Guidance: Read the complete list site, its each value, child callback, props, an throw new Error("every JSX component finding must require manual review"); } + + for (const [index, finding] of report.findings.entries()) { + if ("guidance" in finding || !finding.summary || !finding.reason || finding.nextSteps.length === 0 || finding.cautions.length === 0 || !finding.officialGuideUrl) { + throw new Error("component report must expose structured guidance and stop conditions"); + } + if (formatComponentRenameGuidance(finding) !== guidance[index]) throw new Error("component terminal guidance drifted from its report contract"); + if (finding.snippet.matchStartLine !== finding.line || finding.snippet.matchEndLine < finding.snippet.matchStartLine || finding.snippet.matchEndLine > finding.snippet.endLine) { + throw new Error("snippet must carry the AST match line range"); + } + if (!finding.snippet.text.includes(finding.legacyName)) throw new Error("component snippet must contain the matched element"); + if (finding.snippet.startLine !== Math.max(1, finding.line - 1)) { + throw new Error(`snippet must start one complete line before ${finding.line}`); + } + if (finding.snippet.endLine < finding.line || finding.snippet.text.split("\n").length !== finding.snippet.endLine - finding.snippet.startLine + 1) { + throw new Error(`snippet must include the full match and complete line bounds at ${finding.line}`); + } + } + return null; }; diff --git a/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/component-renames.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/component-renames.ts new file mode 100644 index 0000000..e70e898 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/component-renames.ts @@ -0,0 +1,160 @@ +import type { SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { sourceSnippet, stringLiteralValue } from "../../../../shared/analysis.ts"; +import type { AnalysisRuleResult } from "../../../../shared/report.ts"; +import { formatComponentRenameGuidance, type ComponentRenameContent, type ComponentRenameFinding, type ComponentRenamesReport } from "./report.ts"; + +const SUSPENSE_BOUNDARY_GUIDE = + "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#suspense--errorboundary--loading--errored"; +const INDEX_GUIDE = + "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#list-rendering-index-is-gone-and-for-handles-each-keying-mode"; +const SUSPENSE_LIST_GUIDE = + "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#coordinating-loading-boundaries-suspenselist--reveal"; + +const COMPONENT_MIGRATIONS = { + Suspense: "Loading", + ErrorBoundary: "Errored", + SuspenseList: "Reveal", + Index: "For", +} as const; + +type LegacyComponent = keyof typeof COMPONENT_MIGRATIONS; + +type ComponentSite = { + element: SgNode; + filename: string; + legacyName: LegacyComponent; +}; + +export function analyzeJsxComponentRenames( + rootNode: SgNode, + context: { filename: string }, +): AnalysisRuleResult { + const findings = findImportedComponentSites(rootNode) + .sort((left, right) => { + const leftStart = left.element.range().start; + const rightStart = right.element.range().start; + const filenameOrder = left.filename.localeCompare(right.filename); + if (filenameOrder !== 0) return filenameOrder; + if (leftStart.line !== rightStart.line) { + return leftStart.line - rightStart.line; + } + return leftStart.column - rightStart.column; + }) + .map(({ element, filename, legacyName }): ComponentRenameFinding => { + const start = element.range().start; + return { + filename, + line: start.line + 1, + column: start.column + 1, + legacyName, + replacement: COMPONENT_MIGRATIONS[legacyName], + ...componentContent(legacyName), + snippet: sourceSnippet(element), + }; + }); + return { guidance: findings.map(formatComponentRenameGuidance), report: { findings } }; +} + +function findImportedComponentSites(rootNode: SgNode): ComponentSite[] { + const sites = new Map(); + + for (const statement of rootNode.findAll({ + rule: { kind: "import_statement" }, + })) { + const source = statement.children().find((child) => child.is("string")); + if (!source || stringLiteralValue(source) !== "solid-js") continue; + + for (const specifier of statement.findAll({ + rule: { kind: "import_specifier" }, + })) { + const legacyName = importedLegacyName(specifier); + if (!legacyName) continue; + + const identifiers = specifier.findAll({ rule: { kind: "identifier" } }); + const binding = identifiers[0]; + if (!binding || identifiers.length !== 1) continue; + + for (const fileReferences of binding.references()) { + const filename = fileReferences.root + .relativeFilename() + .replaceAll("\\", "/"); + for (const reference of fileReferences.nodes) { + const element = reference.parent(); + if ( + !element || + (element.kind() !== "jsx_opening_element" && + element.kind() !== "jsx_self_closing_element") || + element.field("name")?.id() !== reference.id() + ) { + continue; + } + sites.set(`${filename}:${element.id()}`, { + element, + filename, + legacyName, + }); + } + } + } + } + + return [...sites.values()]; +} + +function importedLegacyName(specifier: SgNode): LegacyComponent | null { + const text = specifier.text().trim(); + return text in COMPONENT_MIGRATIONS ? (text as LegacyComponent) : null; +} + +function componentContent(legacyName: LegacyComponent): ComponentRenameContent { + const commonValidation = "Make and validate this migration yourself; this analyzer never edits or runs the target project."; + if (legacyName === "Suspense") return { + summary: "Manual review required: migrate this imported Suspense JSX site to Loading.", + reason: "Solid 2 replaces the solid-js Suspense component with Loading for initial not-ready fallback UI.", + nextSteps: [ + "Read this complete boundary, its fallback, children, props, and corresponding import.", + "Replace the unaliased named Suspense import and this JSX component with Loading only after confirming that the boundary owns initial not-ready UI and that its fallback and children preserve their rendering behavior.", + commonValidation, + ], + cautions: ["Stop without proposing a rewrite when props are spread or forwarded, fallback ownership or evaluation is indirect, nested async boundaries make the intended initial-loading behavior unclear, or focused rendering tests do not cover the fallback and ready states."], + validation: ["Ask for the smallest focused test or runtime observation that exposes both states."], + officialGuideUrl: SUSPENSE_BOUNDARY_GUIDE, + }; + if (legacyName === "ErrorBoundary") return { + summary: "Manual review required: migrate this imported ErrorBoundary JSX site to Errored.", + reason: "Solid 2 replaces the solid-js ErrorBoundary component with Errored, whose fallback receives an error accessor rather than a raw error value.", + nextSteps: [ + "Read this complete boundary, its fallback, children, props, and corresponding import.", + "Replace the unaliased named ErrorBoundary import and this JSX component with Errored only after updating every fallback use to read the error accessor, such as err(), while preserving error ownership and recovery behavior.", + commonValidation, + ], + cautions: ["Stop without proposing a rewrite when props are spread or forwarded, the fallback is indirect or escapes, the error value is passed to unknown code, reset or recovery behavior is unclear, or focused tests do not cover thrown and recovered states."], + validation: ["Ask for the smallest focused test or runtime observation that exposes the fallback value and recovery behavior."], + officialGuideUrl: SUSPENSE_BOUNDARY_GUIDE, + }; + if (legacyName === "Index") return { + summary: "Manual review required: migrate this imported Index JSX site to For keyed={false}.", + reason: "Solid 2 removes Index; its direct replacement is For with keyed={false}, whose child callback receives an item accessor and a stable numeric index.", + nextSteps: [ + "Read the complete list site, its each value, child callback, props, and corresponding import.", + "Replace the unaliased named Index import and this JSX component with For, add the literal keyed={false} mode, and review the callback so the item remains an accessor and the index remains a stable number.", + commonValidation, + ], + cautions: ["Stop without proposing a rewrite when props are spread or forwarded, each or the child callback is indirect, callback parameters escape to unknown code, item identity or index behavior is unclear, or focused list-update tests do not prove state preservation."], + validation: ["Ask for the smallest focused test or runtime observation that covers insertion, removal, reordering, and item updates."], + officialGuideUrl: INDEX_GUIDE, + }; + return { + summary: "Manual review required: migrate this imported SuspenseList JSX site to Reveal.", + reason: "Solid 2 replaces SuspenseList with Reveal for coordinating sibling Loading boundaries and replaces revealOrder and tail controls with order and collapsed semantics.", + nextSteps: [ + "Read the complete group, its revealOrder and tail values, children, nesting, props, and corresponding import.", + 'Replace the unaliased named SuspenseList import and this JSX component with Reveal only after mapping literal revealOrder="forwards" to the default or order="sequential", revealOrder="together" to order="together", and tail="collapsed" to collapsed only under sequential order; review the children as sibling Loading boundaries, and do not use the earlier-beta boolean together prop.', + commonValidation, + ], + cautions: ["Stop without proposing a rewrite when props are spread or forwarded, revealOrder or tail is dynamic or has another value, child boundary ownership or nesting is unclear, intended reveal timing cannot be established, or focused behavior tests do not cover the coordinated states."], + validation: ["Ask for the smallest focused test or runtime observation that exposes ordering, fallback, and collapsed-tail behavior."], + officialGuideUrl: SUSPENSE_LIST_GUIDE, + }; +} diff --git a/codemods/solid-migration-assistant/rules/jsx/component-renames/normal.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/normal.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/component-renames/normal.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/normal.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/report.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/report.ts new file mode 100644 index 0000000..b5e04e8 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/report.ts @@ -0,0 +1,35 @@ +import { defineRuleReportAggregator, type SourceSnippet } from "../../../../shared/report.ts"; +export const COMPONENT_RENAMES_RULE_ID = "analysis/jsx/component-renames"; +export const COMPONENT_RENAMES_RULE_ROUTE = "jsx/component-renames"; +export type LegacyComponentName = "Suspense" | "ErrorBoundary" | "SuspenseList" | "Index"; +export type ComponentRenameFinding = { + readonly filename: string; + readonly line: number; + readonly column: number; + readonly legacyName: LegacyComponentName; + readonly replacement: string; + readonly summary: string; + readonly reason: string; + readonly nextSteps: readonly string[]; + readonly cautions: readonly string[]; + readonly validation: readonly string[]; + readonly officialGuideUrl: string; + readonly snippet: SourceSnippet; +}; +export type ComponentRenamesReport = { readonly findings: readonly ComponentRenameFinding[] }; +export type ComponentRenameContent = Pick; + +export function formatComponentRenameGuidance(finding: ComponentRenameFinding): string { + return `${finding.filename}:${finding.line}:${finding.column} ${finding.summary} +Why: ${finding.reason} +Guidance: ${[...finding.nextSteps, ...finding.cautions, ...finding.validation].join(" ")} Official migration guide: ${finding.officialGuideUrl}`; +} +export const componentRenamesReportAggregator = defineRuleReportAggregator({ + id: COMPONENT_RENAMES_RULE_ID, + emptyReport: () => ({ findings: [] }), + merge: (projectReport, nextReport) => ({ findings: [...projectReport.findings, ...nextReport.findings].sort(compareFindings) }), +}); +function compareFindings(left: ComponentRenameFinding, right: ComponentRenameFinding): number { + const filenameOrder = left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0; + return filenameOrder || left.line - right.line || left.column - right.column; +} diff --git a/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/ui.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/ui.tsx new file mode 100644 index 0000000..982a0f2 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/jsx/component-renames/ui.tsx @@ -0,0 +1,39 @@ +import { GuidanceSections, RuleFindings } from "../../../../dashboard/finding-ui.tsx"; +import { formatFindingLocation } from "../../../../dashboard/finding-model.ts"; +import { defineRuleSlice } from "../../../../shared/report.ts"; +import { + COMPONENT_RENAMES_RULE_ID, + COMPONENT_RENAMES_RULE_ROUTE, + type ComponentRenamesReport, +} from "./report.ts"; + +export const componentRenamesSlice = defineRuleSlice({ + id: COMPONENT_RENAMES_RULE_ID, + route: COMPONENT_RENAMES_RULE_ROUTE, + title: "Component renames", + domain: "JSX", + kind: "analysis", + Summary: (props) =>

{props.report.findings.length} component sites need review

, + Detail: (props) => ( + ({ + filename: finding.filename, + location: formatFindingLocation(finding.filename, finding.line, finding.column), + label: <>{finding.legacyName}{finding.replacement}, + snippet: finding.snippet, + editorTarget: { + analyzedTargetRoot: props.run.analyzedTargetRoot, + filename: finding.filename, + line: finding.line, + column: finding.column, + }, + details: , + }))} /> + ), +}); diff --git a/codemods/solid-migration-assistant/rules/jsx/context-provider/context-provider.test.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/context-provider/context-provider.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/context-provider/context-provider.test.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/context-provider/context-provider.test.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/context-provider/context-provider.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/context-provider/context-provider.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/context-provider/context-provider.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/context-provider/context-provider.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/context-provider/normal.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/context-provider/normal.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/context-provider/normal.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/jsx/context-provider/normal.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-attr-namespaces/dom-attr-namespaces.test.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-attr-namespaces/dom-attr-namespaces.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-attr-namespaces/dom-attr-namespaces.test.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-attr-namespaces/dom-attr-namespaces.test.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-attr-namespaces/dom-attr-namespaces.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-attr-namespaces/dom-attr-namespaces.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-attr-namespaces/dom-attr-namespaces.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-attr-namespaces/dom-attr-namespaces.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-attr-namespaces/normal.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-attr-namespaces/normal.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-attr-namespaces/normal.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-attr-namespaces/normal.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-event-namespaces/dom-event-namespaces.test.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-event-namespaces/dom-event-namespaces.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-event-namespaces/dom-event-namespaces.test.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-event-namespaces/dom-event-namespaces.test.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-event-namespaces/dom-event-namespaces.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-event-namespaces/dom-event-namespaces.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-event-namespaces/dom-event-namespaces.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-event-namespaces/dom-event-namespaces.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-event-namespaces/normal.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-event-namespaces/normal.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-event-namespaces/normal.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-event-namespaces/normal.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-use-directive/dom-use-directive.test.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-use-directive/dom-use-directive.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-use-directive/dom-use-directive.test.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-use-directive/dom-use-directive.test.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-use-directive/dom-use-directive.ts b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-use-directive/dom-use-directive.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-use-directive/dom-use-directive.ts rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-use-directive/dom-use-directive.ts diff --git a/codemods/solid-migration-assistant/rules/jsx/dom-use-directive/normal.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/jsx/dom-use-directive/normal.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/jsx/dom-use-directive/normal.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/jsx/dom-use-directive/normal.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/on-cleanup.test.ts b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/on-cleanup.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/on-cleanup.test.ts rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/on-cleanup.test.ts diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/on-cleanup.ts b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/on-cleanup.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/on-cleanup.ts rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/on-cleanup.ts index 5e26335..ac6fb82 100644 --- a/codemods/solid-migration-assistant/rules/lifecycle/on-cleanup/on-cleanup.ts +++ b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-cleanup/on-cleanup.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#effects-lifecycle-and-cleanup"; diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-mount/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/lifecycle/on-mount/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-mount/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/lifecycle/on-mount/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-mount/on-mount.test.ts b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/on-mount.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/lifecycle/on-mount/on-mount.test.ts rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/on-mount.test.ts diff --git a/codemods/solid-migration-assistant/rules/lifecycle/on-mount/on-mount.ts b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/on-mount.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/lifecycle/on-mount/on-mount.ts rename to codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/on-mount.ts index 745a8e2..24451ee 100644 --- a/codemods/solid-migration-assistant/rules/lifecycle/on-mount/on-mount.ts +++ b/codemods/solid-migration-assistant/rules/analysis/lifecycle/on-mount/on-mount.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#effects-lifecycle-and-cleanup"; diff --git a/codemods/solid-migration-assistant/rules/props/merge-props/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/props/merge-props/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/props/merge-props/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/props/merge-props/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/props/merge-props/merge-props.test.ts b/codemods/solid-migration-assistant/rules/analysis/props/merge-props/merge-props.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/props/merge-props/merge-props.test.ts rename to codemods/solid-migration-assistant/rules/analysis/props/merge-props/merge-props.test.ts diff --git a/codemods/solid-migration-assistant/rules/props/merge-props/merge-props.ts b/codemods/solid-migration-assistant/rules/analysis/props/merge-props/merge-props.ts similarity index 97% rename from codemods/solid-migration-assistant/rules/props/merge-props/merge-props.ts rename to codemods/solid-migration-assistant/rules/analysis/props/merge-props/merge-props.ts index a216993..292ec3a 100644 --- a/codemods/solid-migration-assistant/rules/props/merge-props/merge-props.ts +++ b/codemods/solid-migration-assistant/rules/analysis/props/merge-props/merge-props.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#mergeprops--splitprops--merge--omit"; diff --git a/codemods/solid-migration-assistant/rules/props/merge-props/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/props/merge-props/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/props/merge-props/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/props/merge-props/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/props/split-props/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/props/split-props/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/props/split-props/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/props/split-props/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/props/split-props/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/props/split-props/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/props/split-props/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/props/split-props/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/props/split-props/split-props.test.ts b/codemods/solid-migration-assistant/rules/analysis/props/split-props/split-props.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/props/split-props/split-props.test.ts rename to codemods/solid-migration-assistant/rules/analysis/props/split-props/split-props.test.ts diff --git a/codemods/solid-migration-assistant/rules/props/split-props/split-props.ts b/codemods/solid-migration-assistant/rules/analysis/props/split-props/split-props.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/props/split-props/split-props.ts rename to codemods/solid-migration-assistant/rules/analysis/props/split-props/split-props.ts index c858298..8279115 100644 --- a/codemods/solid-migration-assistant/rules/props/split-props/split-props.ts +++ b/codemods/solid-migration-assistant/rules/analysis/props/split-props/split-props.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#mergeprops--splitprops--merge--omit"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/batch/batch.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/batch/batch.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/batch/batch.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/batch/batch.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/batch/batch.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/batch/batch.ts similarity index 95% rename from codemods/solid-migration-assistant/rules/reactivity/batch/batch.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/batch/batch.ts index ce7ccf8..626a28b 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/batch/batch.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/batch/batch.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#batch--default-microtask-batching--flush"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/batch/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/batch/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/batch/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/batch/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/batch/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/batch/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/batch/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/batch/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-computed/create-computed.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/create-computed.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-computed/create-computed.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/create-computed.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-computed/create-computed.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/create-computed.ts similarity index 97% rename from codemods/solid-migration-assistant/rules/reactivity/create-computed/create-computed.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/create-computed.ts index 9aee01e..45f10e5 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/create-computed/create-computed.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/create-computed.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#createcomputed--creatememo-createeffect-or-derived-createsignal"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-computed/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-computed/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-computed/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-computed/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-computed/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-dynamic/create-dynamic.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/create-dynamic.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-dynamic/create-dynamic.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/create-dynamic.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-dynamic/create-dynamic.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/create-dynamic.ts similarity index 95% rename from codemods/solid-migration-assistant/rules/reactivity/create-dynamic/create-dynamic.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/create-dynamic.ts index 6149377..196e77f 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/create-dynamic/create-dynamic.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/create-dynamic.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-dynamic/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-dynamic/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-dynamic/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-dynamic/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-dynamic/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-effect/create-effect.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/create-effect.test.ts similarity index 66% rename from codemods/solid-migration-assistant/rules/reactivity/create-effect/create-effect.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/create-effect.test.ts index 71ede24..cd24ed0 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/create-effect/create-effect.test.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/create-effect.test.ts @@ -1,13 +1,14 @@ import type { Codemod } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; import { analyzeCreateEffect } from "./create-effect.ts"; +import { formatCreateEffectGuidance } from "./report.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#effects-lifecycle-and-cleanup"; const testCreateEffectRule: Codemod = async (root) => { const filename = root.relativeFilename().replaceAll("\\", "/"); - const guidance = analyzeCreateEffect(root.root(), { filename }); + const { guidance, report } = analyzeCreateEffect(root.root(), { filename }); const isFixture = root.source().includes('from "solid-js"') || root.source().includes('from "solid\\x2djs"'); @@ -78,6 +79,24 @@ const testCreateEffectRule: Codemod = async (root) => { ); } + + for (const [index, finding] of report.findings.entries()) { + if ("guidance" in finding || !finding.summary || !finding.reason || finding.nextSteps.length === 0 || !finding.officialGuideUrl) { + throw new Error("createEffect report must expose structured guidance fields only"); + } + if (formatCreateEffectGuidance(finding) !== guidance[index]) throw new Error("createEffect terminal guidance drifted from its report contract"); + if (finding.snippet.matchStartLine !== finding.line || finding.snippet.matchEndLine < finding.snippet.matchStartLine || finding.snippet.matchEndLine > finding.snippet.endLine) { + throw new Error("snippet must carry the AST match line range"); + } + if (!finding.snippet.text.includes("createEffect")) throw new Error("createEffect snippet must contain the matched call"); + if (finding.snippet.startLine !== Math.max(1, finding.line - 1)) { + throw new Error(`snippet must start one complete line before ${finding.line}`); + } + if (finding.snippet.endLine < finding.line || finding.snippet.text.split("\n").length !== finding.snippet.endLine - finding.snippet.startLine + 1) { + throw new Error(`snippet must include the full match and complete line bounds at ${finding.line}`); + } + } + return null; }; diff --git a/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/create-effect.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/create-effect.ts new file mode 100644 index 0000000..0571eba --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/create-effect.ts @@ -0,0 +1,64 @@ +import type { SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { findImportedCalls, sourceSnippet } from "../../../../shared/analysis.ts"; +import type { AnalysisRuleResult } from "../../../../shared/report.ts"; +import { formatCreateEffectGuidance, type CreateEffectContent, type CreateEffectFinding, type CreateEffectReport } from "./report.ts"; + +const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#effects-lifecycle-and-cleanup"; +const MANUAL_VALIDATION = "Make and validate this migration yourself; this analyzer never edits or runs the target project."; + +export function analyzeCreateEffect(rootNode: SgNode, context: { filename: string }): AnalysisRuleResult { + const findings: CreateEffectFinding[] = findImportedCalls(rootNode, ["solid-js", "solid-js/web"], "createEffect") + .filter(({ argumentNodes }) => !argumentNodes.some((a) => a.kind() === "spread_element")) + .map(({ call, argumentNodes, filename }) => { + const start = call.range().start; + const argumentCount = argumentNodes.length; + return { + filename, line: start.line + 1, column: start.column + 1, argumentCount, + ...contentForArgumentCount(argumentCount), snippet: sourceSnippet(call), + }; + }); + return { guidance: findings.map(formatCreateEffectGuidance), report: { findings } }; +} + +function contentForArgumentCount(argumentCount: number): CreateEffectContent { + if (argumentCount === 0) return { + summary: "Manual review required: migrate this zero-argument createEffect call.", + reason: "Solid 2 requires separate compute and apply callbacks; a zero-argument call is likely a stub or placeholder.", + nextSteps: ["Add explicit compute and apply callbacks, or remove the empty call if it is dead code.", MANUAL_VALIDATION], + cautions: [], validation: [], officialGuideUrl: MIGRATION_GUIDE, + }; + if (argumentCount === 1) return { + summary: "Manual review required: split this one-argument createEffect into compute and apply callbacks.", + reason: "Solid 2 requires separate compute and apply callbacks; the correct split depends on which reads are reactive inputs and which statements are side effects.", + nextSteps: [ + "Read the full callback, imports, and nearby reactive declarations.", + "Identify the reactive reads that should trigger the effect, move those reads into the compute callback, return the value the side effect needs, and perform the imperative operation in the apply callback without adding reactive dependencies.", + MANUAL_VALIDATION, + ], + cautions: ["Stop without proposing a rewrite when the effect contains cleanup, async work, nested control flow affecting reads, reactive primitive creation, unrelated operations, writes that may affect its own inputs, or unclear intent."], + validation: ["Ask for the smallest focused test or runtime observation that makes the missing behavior decision observable."], officialGuideUrl: MIGRATION_GUIDE, + }; + if (argumentCount === 2) return { + summary: "Manual review required: migrate this two-argument createEffect call.", + reason: "Solid 2 treats the second argument as options, not an initial value. If this call's second argument is an initial value, it must move to a default parameter of the compute callback. If it is already an apply callback for the split-effect pattern, confirm compatibility with Solid 2 option handling.", + nextSteps: [ + "Read both arguments.", + "If the second argument is a non-function initial value or a function that represents the legacy initialValue parameter, move it to a default parameter on the compute callback: createEffect((prev = INITIAL_VALUE) => compute(prev), applyFn).", + "If the second argument is already the apply function for a manually split effect, verify that no Solid 1.x initialValue semantic is accidentally carried forward and that the call expects the second argument to be options in Solid 2.", + MANUAL_VALIDATION, + ], + cautions: ["Stop without proposing a rewrite when the intent of the second argument is unclear, the callback contains cleanup or async work, or the distinction between initialValue and apply callback cannot be resolved from static context."], + validation: ["Ask for the smallest focused test or runtime observation that makes the second-argument intent observable."], officialGuideUrl: MIGRATION_GUIDE, + }; + return { + summary: `Manual review required: migrate this ${argumentCount}-argument createEffect call.`, + reason: "Solid 2 treats the second argument as options, not an initial value, and does not support a third positional argument. Legacy three-argument calls (callback, initialValue, options) must be restructured.", + nextSteps: [ + "Move the initial value to a default parameter on the compute callback, and move any legacy options that have Solid 2 equivalents into the second-argument options object.", + "Verify that every legacy option is either mapped to a Solid 2 equivalent or intentionally dropped.", MANUAL_VALIDATION, + ], + cautions: ["Stop without proposing a rewrite when the intent of each argument is unclear."], + validation: ["Ask for the smallest focused test or runtime observation that makes the argument roles observable."], officialGuideUrl: MIGRATION_GUIDE, + }; +} diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-effect/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-effect/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-effect/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-effect/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/report.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/report.ts new file mode 100644 index 0000000..c862f6f --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/report.ts @@ -0,0 +1,26 @@ +import { defineRuleReportAggregator, type SourceSnippet } from "../../../../shared/report.ts"; +export const CREATE_EFFECT_RULE_ID = "analysis/reactivity/create-effect"; +export const CREATE_EFFECT_RULE_ROUTE = "reactivity/create-effect"; +export type CreateEffectFinding = { + readonly filename: string; readonly line: number; readonly column: number; + readonly argumentCount: number; + readonly summary: string; readonly reason: string; + readonly nextSteps: readonly string[]; readonly cautions: readonly string[]; + readonly validation: readonly string[]; readonly officialGuideUrl: string; + readonly snippet: SourceSnippet; +}; +export type CreateEffectReport = { readonly findings: readonly CreateEffectFinding[] }; +export type CreateEffectContent = Pick; +export function formatCreateEffectGuidance(finding: CreateEffectFinding): string { + return `${finding.filename}:${finding.line}:${finding.column} ${finding.summary} +Why: ${finding.reason} +Guidance: ${[...finding.nextSteps, ...finding.cautions, ...finding.validation].join(" ")} Official migration guide: ${finding.officialGuideUrl}`; +} +export const createEffectReportAggregator = defineRuleReportAggregator({ + id: CREATE_EFFECT_RULE_ID, emptyReport: () => ({ findings: [] }), + merge: (projectReport, nextReport) => ({ findings: [...projectReport.findings, ...nextReport.findings].sort(compareFindings) }), +}); +function compareFindings(left: CreateEffectFinding, right: CreateEffectFinding): number { + const filenameOrder = left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0; + return filenameOrder || left.line - right.line || left.column - right.column; +} diff --git a/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/ui.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/ui.tsx new file mode 100644 index 0000000..7212515 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-effect/ui.tsx @@ -0,0 +1,39 @@ +import { GuidanceSections, RuleFindings } from "../../../../dashboard/finding-ui.tsx"; +import { formatFindingLocation } from "../../../../dashboard/finding-model.ts"; +import { defineRuleSlice } from "../../../../shared/report.ts"; +import { + CREATE_EFFECT_RULE_ID, + CREATE_EFFECT_RULE_ROUTE, + type CreateEffectReport, +} from "./report.ts"; + +export const createEffectSlice = defineRuleSlice({ + id: CREATE_EFFECT_RULE_ID, + route: CREATE_EFFECT_RULE_ROUTE, + title: "createEffect calls", + domain: "Reactivity", + kind: "analysis", + Summary: (props) =>

{props.report.findings.length} createEffect calls need review

, + Detail: (props) => ( + ({ + filename: finding.filename, + location: formatFindingLocation(finding.filename, finding.line, finding.column), + label: `${finding.argumentCount} positional ${finding.argumentCount === 1 ? "argument" : "arguments"}`, + snippet: finding.snippet, + editorTarget: { + analyzedTargetRoot: props.run.analyzedTargetRoot, + filename: finding.filename, + line: finding.line, + column: finding.column, + }, + details: , + }))} /> + ), +}); diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-memo/create-memo.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/create-memo.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-memo/create-memo.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/create-memo.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-memo/create-memo.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/create-memo.ts similarity index 97% rename from codemods/solid-migration-assistant/rules/reactivity/create-memo/create-memo.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/create-memo.ts index c3256da..1dcea44 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/create-memo/create-memo.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/create-memo.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#effects-lifecycle-and-cleanup"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-memo/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-memo/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-memo/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-memo/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-memo/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-resource/create-resource.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/create-resource.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-resource/create-resource.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/create-resource.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-resource/create-resource.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/create-resource.ts similarity index 98% rename from codemods/solid-migration-assistant/rules/reactivity/create-resource/create-resource.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/create-resource.ts index 6cd49cd..6d60c44 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/create-resource/create-resource.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/create-resource.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#createresource--async-computations--loading"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-resource/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-resource/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-resource/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-resource/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-resource/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-selector/create-selector.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/create-selector.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-selector/create-selector.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/create-selector.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-selector/create-selector.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/create-selector.ts similarity index 95% rename from codemods/solid-migration-assistant/rules/reactivity/create-selector/create-selector.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/create-selector.ts index f36555e..c3b643c 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/create-selector/create-selector.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/create-selector.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-selector/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-selector/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-selector/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/create-selector/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/create-selector/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/error-handling/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/error-handling/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/error-handling/error-handling.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/error-handling.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/error-handling/error-handling.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/error-handling.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/error-handling/error-handling.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/error-handling.ts similarity index 98% rename from codemods/solid-migration-assistant/rules/reactivity/error-handling/error-handling.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/error-handling.ts index 0470f34..84535a1 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/error-handling/error-handling.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/error-handling.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#onerror--catcherror--errored--effect-error-option"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/error-handling/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/error-handling/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/error-handling/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/from-observable/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/from-observable/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/from-observable/from-observable.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/from-observable.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/from-observable/from-observable.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/from-observable.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/from-observable/from-observable.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/from-observable.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/reactivity/from-observable/from-observable.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/from-observable.ts index 07f5a6e..08a39f6 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/from-observable/from-observable.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/from-observable.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/from-observable/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/from-observable/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/from-observable/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/index-array/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/index-array/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/index-array/index-array.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/index-array.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/index-array/index-array.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/index-array.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/index-array/index-array.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/index-array.ts similarity index 94% rename from codemods/solid-migration-assistant/rules/reactivity/index-array/index-array.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/index-array.ts index 76387ef..7302f09 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/index-array/index-array.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/index-array.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/index-array/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/index-array/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/index-array/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/on-helper/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/on-helper/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/on-helper/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/on-helper/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/on-helper/on-helper.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/on-helper.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/on-helper/on-helper.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/on-helper.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/on-helper/on-helper.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/on-helper.ts similarity index 95% rename from codemods/solid-migration-assistant/rules/reactivity/on-helper/on-helper.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/on-helper.ts index 9878634..6cbeaba 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/on-helper/on-helper.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/on-helper/on-helper.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#on-helper--split-effects"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/transition-apis/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/transition-apis/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/transition-apis/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/transition-apis/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/transition-apis/transition-apis.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/transition-apis.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/transition-apis/transition-apis.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/transition-apis.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/transition-apis/transition-apis.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/transition-apis.ts similarity index 97% rename from codemods/solid-migration-assistant/rules/reactivity/transition-apis/transition-apis.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/transition-apis.ts index 0b6c652..9808a4c 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/transition-apis/transition-apis.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/transition-apis/transition-apis.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md"; diff --git a/codemods/solid-migration-assistant/rules/reactivity/utility-renames/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/utility-renames/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/utility-renames/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/utility-renames/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/reactivity/utility-renames/utility-renames.test.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/utility-renames.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/reactivity/utility-renames/utility-renames.test.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/utility-renames.test.ts diff --git a/codemods/solid-migration-assistant/rules/reactivity/utility-renames/utility-renames.ts b/codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/utility-renames.ts similarity index 97% rename from codemods/solid-migration-assistant/rules/reactivity/utility-renames/utility-renames.ts rename to codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/utility-renames.ts index ffe0e46..561cbcb 100644 --- a/codemods/solid-migration-assistant/rules/reactivity/utility-renames/utility-renames.ts +++ b/codemods/solid-migration-assistant/rules/analysis/reactivity/utility-renames/utility-renames.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md"; diff --git a/codemods/solid-migration-assistant/rules/store/mutable/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/store/mutable/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/store/mutable/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/store/mutable/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/store/mutable/mutable.test.ts b/codemods/solid-migration-assistant/rules/analysis/store/mutable/mutable.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/store/mutable/mutable.test.ts rename to codemods/solid-migration-assistant/rules/analysis/store/mutable/mutable.test.ts diff --git a/codemods/solid-migration-assistant/rules/store/mutable/mutable.ts b/codemods/solid-migration-assistant/rules/analysis/store/mutable/mutable.ts similarity index 98% rename from codemods/solid-migration-assistant/rules/store/mutable/mutable.ts rename to codemods/solid-migration-assistant/rules/analysis/store/mutable/mutable.ts index 64f241e..f671a13 100644 --- a/codemods/solid-migration-assistant/rules/store/mutable/mutable.ts +++ b/codemods/solid-migration-assistant/rules/analysis/store/mutable/mutable.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#createmutable--modifymutable--createstore-with-draft-setters"; diff --git a/codemods/solid-migration-assistant/rules/store/mutable/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/store/mutable/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/store/mutable/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/store/mutable/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/store/produce/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/store/produce/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/store/produce/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/store/produce/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/store/produce/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/store/produce/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/store/produce/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/store/produce/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/store/produce/produce.test.ts b/codemods/solid-migration-assistant/rules/analysis/store/produce/produce.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/store/produce/produce.test.ts rename to codemods/solid-migration-assistant/rules/analysis/store/produce/produce.test.ts diff --git a/codemods/solid-migration-assistant/rules/store/produce/produce.ts b/codemods/solid-migration-assistant/rules/analysis/store/produce/produce.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/store/produce/produce.ts rename to codemods/solid-migration-assistant/rules/analysis/store/produce/produce.ts index 2ab3741..9e21134 100644 --- a/codemods/solid-migration-assistant/rules/store/produce/produce.ts +++ b/codemods/solid-migration-assistant/rules/analysis/store/produce/produce.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#produce--now-the-default-setter-behavior"; diff --git a/codemods/solid-migration-assistant/rules/store/unwrap/direct-call.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/store/unwrap/direct-call.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/store/unwrap/direct-call.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/store/unwrap/direct-call.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/store/unwrap/non-solid.fixture.tsx b/codemods/solid-migration-assistant/rules/analysis/store/unwrap/non-solid.fixture.tsx similarity index 100% rename from codemods/solid-migration-assistant/rules/store/unwrap/non-solid.fixture.tsx rename to codemods/solid-migration-assistant/rules/analysis/store/unwrap/non-solid.fixture.tsx diff --git a/codemods/solid-migration-assistant/rules/store/unwrap/unwrap.test.ts b/codemods/solid-migration-assistant/rules/analysis/store/unwrap/unwrap.test.ts similarity index 100% rename from codemods/solid-migration-assistant/rules/store/unwrap/unwrap.test.ts rename to codemods/solid-migration-assistant/rules/analysis/store/unwrap/unwrap.test.ts diff --git a/codemods/solid-migration-assistant/rules/store/unwrap/unwrap.ts b/codemods/solid-migration-assistant/rules/analysis/store/unwrap/unwrap.ts similarity index 96% rename from codemods/solid-migration-assistant/rules/store/unwrap/unwrap.ts rename to codemods/solid-migration-assistant/rules/analysis/store/unwrap/unwrap.ts index d528ea7..bbb5573 100644 --- a/codemods/solid-migration-assistant/rules/store/unwrap/unwrap.ts +++ b/codemods/solid-migration-assistant/rules/analysis/store/unwrap/unwrap.ts @@ -1,6 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; +import { findImportedCalls } from "../../../../shared/analysis.ts"; const MIGRATION_GUIDE = "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#unwrapstore--snapshotstore"; diff --git a/codemods/solid-migration-assistant/rules/imports/web-import/web-import.ts b/codemods/solid-migration-assistant/rules/imports/web-import/web-import.ts deleted file mode 100644 index d4e4657..0000000 --- a/codemods/solid-migration-assistant/rules/imports/web-import/web-import.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SgNode } from "codemod:ast-grep"; -import type TSX from "codemod:ast-grep/langs/tsx"; -import { findModuleReferences } from "../../../shared/analysis.ts"; -import type { ModuleReference } from "../../../shared/analysis.ts"; - -const LEGACY_WEB_MODULE = "solid-js/web"; -const MIGRATION_GUIDE = - "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#imports-where-things-live-now"; - -const FORM_LABELS: Record = { - import: "static import", - "re-export": "re-export", - "dynamic-import": "dynamic import()", - require: "require() call", -}; - -const FORM_GUIDANCE: Record = { - import: - " Change only this static import's module source to @solidjs/web and preserve its import form and quote style.", - "re-export": - " Change only this re-export's module source to @solidjs/web and preserve its export form and quote style.", - "dynamic-import": - " Change only this dynamic import's module source to @solidjs/web and preserve its quote style.", - require: - " Change only this require call's module source to @solidjs/web and preserve its quote style.", -}; - -export function analyzeWebImport( - rootNode: SgNode, - context: { filename: string }, -): string[] { - return findModuleReferences(rootNode) - .filter((ref) => ref.moduleName === LEGACY_WEB_MODULE) - .map(({ source, form }) => { - const start = source.range().start; - return `${context.filename}:${start.line + 1}:${start.column + 1} Move this Solid web renderer ${FORM_LABELS[form]}. -Why: Solid 2 publishes the web renderer from @solidjs/web instead of the solid-js/web subpath. -Guidance:${FORM_GUIDANCE[form]} Make and validate that edit yourself; this analyzer never edits or runs the target project. Official migration guide: ${MIGRATION_GUIDE}`; - }); -} \ No newline at end of file diff --git a/codemods/solid-migration-assistant/rules/jsx/component-renames/component-renames.ts b/codemods/solid-migration-assistant/rules/jsx/component-renames/component-renames.ts deleted file mode 100644 index 8df6403..0000000 --- a/codemods/solid-migration-assistant/rules/jsx/component-renames/component-renames.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { SgNode } from "codemod:ast-grep"; -import type TSX from "codemod:ast-grep/langs/tsx"; -import { stringLiteralValue } from "../../../shared/analysis.ts"; - -const SUSPENSE_BOUNDARY_GUIDE = - "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#suspense--errorboundary--loading--errored"; -const INDEX_GUIDE = - "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#list-rendering-index-is-gone-and-for-handles-each-keying-mode"; -const SUSPENSE_LIST_GUIDE = - "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#coordinating-loading-boundaries-suspenselist--reveal"; - -const COMPONENT_MIGRATIONS = { - Suspense: "Loading", - ErrorBoundary: "Errored", - SuspenseList: "Reveal", - Index: "For", -} as const; - -type LegacyComponent = keyof typeof COMPONENT_MIGRATIONS; - -type ComponentSite = { - element: SgNode; - filename: string; - legacyName: LegacyComponent; -}; - -export function analyzeJsxComponentRenames( - rootNode: SgNode, - context: { filename: string }, -): string[] { - return findImportedComponentSites(rootNode) - .sort((left, right) => { - const leftStart = left.element.range().start; - const rightStart = right.element.range().start; - const filenameOrder = left.filename.localeCompare(right.filename); - if (filenameOrder !== 0) return filenameOrder; - if (leftStart.line !== rightStart.line) { - return leftStart.line - rightStart.line; - } - return leftStart.column - rightStart.column; - }) - .map(({ element, filename, legacyName }) => - componentGuidance(element, filename, legacyName), - ); -} - -function findImportedComponentSites(rootNode: SgNode): ComponentSite[] { - const sites = new Map(); - - for (const statement of rootNode.findAll({ - rule: { kind: "import_statement" }, - })) { - const source = statement.children().find((child) => child.is("string")); - if (!source || stringLiteralValue(source) !== "solid-js") continue; - - for (const specifier of statement.findAll({ - rule: { kind: "import_specifier" }, - })) { - const legacyName = importedLegacyName(specifier); - if (!legacyName) continue; - - const identifiers = specifier.findAll({ rule: { kind: "identifier" } }); - const binding = identifiers[0]; - if (!binding || identifiers.length !== 1) continue; - - for (const fileReferences of binding.references()) { - const filename = fileReferences.root - .relativeFilename() - .replaceAll("\\", "/"); - for (const reference of fileReferences.nodes) { - const element = reference.parent(); - if ( - !element || - (element.kind() !== "jsx_opening_element" && - element.kind() !== "jsx_self_closing_element") || - element.field("name")?.id() !== reference.id() - ) { - continue; - } - sites.set(`${filename}:${element.id()}`, { - element, - filename, - legacyName, - }); - } - } - } - } - - return [...sites.values()]; -} - -function importedLegacyName(specifier: SgNode): LegacyComponent | null { - const text = specifier.text().trim(); - return text in COMPONENT_MIGRATIONS ? (text as LegacyComponent) : null; -} - -function componentGuidance( - element: SgNode, - filename: string, - legacyName: LegacyComponent, -): string { - const start = element.range().start; - const location = `${filename}:${start.line + 1}:${start.column + 1}`; - - if (legacyName === "Suspense") { - return `${location} Manual review required: migrate this imported Suspense JSX site to Loading. -Why: Solid 2 replaces the solid-js Suspense component with Loading for initial not-ready fallback UI. -Guidance: Read this complete boundary, its fallback, children, props, and corresponding import. Replace the unaliased named Suspense import and this JSX component with Loading only after confirming that the boundary owns initial not-ready UI and that its fallback and children preserve their rendering behavior. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when props are spread or forwarded, fallback ownership or evaluation is indirect, nested async boundaries make the intended initial-loading behavior unclear, or focused rendering tests do not cover the fallback and ready states. Ask for the smallest focused test or runtime observation that exposes both states. Official migration guide: ${SUSPENSE_BOUNDARY_GUIDE}`; - } - - if (legacyName === "ErrorBoundary") { - return `${location} Manual review required: migrate this imported ErrorBoundary JSX site to Errored. -Why: Solid 2 replaces the solid-js ErrorBoundary component with Errored, whose fallback receives an error accessor rather than a raw error value. -Guidance: Read this complete boundary, its fallback, children, props, and corresponding import. Replace the unaliased named ErrorBoundary import and this JSX component with Errored only after updating every fallback use to read the error accessor, such as err(), while preserving error ownership and recovery behavior. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when props are spread or forwarded, the fallback is indirect or escapes, the error value is passed to unknown code, reset or recovery behavior is unclear, or focused tests do not cover thrown and recovered states. Ask for the smallest focused test or runtime observation that exposes the fallback value and recovery behavior. Official migration guide: ${SUSPENSE_BOUNDARY_GUIDE}`; - } - - if (legacyName === "Index") { - return `${location} Manual review required: migrate this imported Index JSX site to For keyed={false}. -Why: Solid 2 removes Index; its direct replacement is For with keyed={false}, whose child callback receives an item accessor and a stable numeric index. -Guidance: Read the complete list site, its each value, child callback, props, and corresponding import. Replace the unaliased named Index import and this JSX component with For, add the literal keyed={false} mode, and review the callback so the item remains an accessor and the index remains a stable number. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when props are spread or forwarded, each or the child callback is indirect, callback parameters escape to unknown code, item identity or index behavior is unclear, or focused list-update tests do not prove state preservation. Ask for the smallest focused test or runtime observation that covers insertion, removal, reordering, and item updates. Official migration guide: ${INDEX_GUIDE}`; - } - - return `${location} Manual review required: migrate this imported SuspenseList JSX site to Reveal. -Why: Solid 2 replaces SuspenseList with Reveal for coordinating sibling Loading boundaries and replaces revealOrder and tail controls with order and collapsed semantics. -Guidance: Read the complete group, its revealOrder and tail values, children, nesting, props, and corresponding import. Replace the unaliased named SuspenseList import and this JSX component with Reveal only after mapping literal revealOrder="forwards" to the default or order="sequential", revealOrder="together" to order="together", and tail="collapsed" to collapsed only under sequential order; review the children as sibling Loading boundaries, and do not use the earlier-beta boolean together prop. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when props are spread or forwarded, revealOrder or tail is dynamic or has another value, child boundary ownership or nesting is unclear, intended reveal timing cannot be established, or focused behavior tests do not cover the coordinated states. Ask for the smallest focused test or runtime observation that exposes ordering, fallback, and collapsed-tail behavior. Official migration guide: ${SUSPENSE_LIST_GUIDE}`; -} - diff --git a/codemods/solid-migration-assistant/rules/reactivity/create-effect/create-effect.ts b/codemods/solid-migration-assistant/rules/reactivity/create-effect/create-effect.ts deleted file mode 100644 index db25717..0000000 --- a/codemods/solid-migration-assistant/rules/reactivity/create-effect/create-effect.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { SgNode } from "codemod:ast-grep"; -import type TSX from "codemod:ast-grep/langs/tsx"; -import { findImportedCalls } from "../../../shared/analysis.ts"; - -const MIGRATION_GUIDE = - "https://github.com/solidjs/solid/blob/ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5/documentation/solid-2.0/MIGRATION.md#effects-lifecycle-and-cleanup"; - -export function analyzeCreateEffect( - rootNode: SgNode, - context: { filename: string }, -): string[] { - return findImportedCalls(rootNode, ["solid-js", "solid-js/web"], "createEffect") - .filter( - ({ argumentNodes }) => - !argumentNodes.some((a) => a.kind() === "spread_element"), - ) - .map(({ call, argumentNodes, filename }) => { - const start = call.range().start; - const argCount = argumentNodes.length; - - if (argCount === 0) { - return `${filename}:${start.line + 1}:${start.column + 1} Manual review required: migrate this zero-argument createEffect call. -Why: Solid 2 requires separate compute and apply callbacks; a zero-argument call is likely a stub or placeholder. -Guidance: Add explicit compute and apply callbacks, or remove the empty call if it is dead code. Make and validate this migration yourself; this analyzer never edits or runs the target project. Official migration guide: ${MIGRATION_GUIDE}`; - } - - if (argCount === 1) { - return `${filename}:${start.line + 1}:${start.column + 1} Manual review required: split this one-argument createEffect into compute and apply callbacks. -Why: Solid 2 requires separate compute and apply callbacks; the correct split depends on which reads are reactive inputs and which statements are side effects. -Guidance: Read the full callback, imports, and nearby reactive declarations. Identify the reactive reads that should trigger the effect, move those reads into the compute callback, return the value the side effect needs, and perform the imperative operation in the apply callback without adding reactive dependencies. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when the effect contains cleanup, async work, nested control flow affecting reads, reactive primitive creation, unrelated operations, writes that may affect its own inputs, or unclear intent. Ask for the smallest focused test or runtime observation that makes the missing behavior decision observable. Official migration guide: ${MIGRATION_GUIDE}`; - } - - if (argCount === 2) { - return `${filename}:${start.line + 1}:${start.column + 1} Manual review required: migrate this two-argument createEffect call. -Why: Solid 2 treats the second argument as options, not an initial value. If this call's second argument is an initial value, it must move to a default parameter of the compute callback. If it is already an apply callback for the split-effect pattern, confirm compatibility with Solid 2 option handling. -Guidance: Read both arguments. If the second argument is a non-function initial value or a function that represents the legacy initialValue parameter, move it to a default parameter on the compute callback: createEffect((prev = INITIAL_VALUE) => compute(prev), applyFn). If the second argument is already the apply function for a manually split effect, verify that no Solid 1.x initialValue semantic is accidentally carried forward and that the call expects the second argument to be options in Solid 2. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when the intent of the second argument is unclear, the callback contains cleanup or async work, or the distinction between initialValue and apply callback cannot be resolved from static context. Ask for the smallest focused test or runtime observation that makes the second-argument intent observable. Official migration guide: ${MIGRATION_GUIDE}`; - } - - // argCount >= 3 - return `${filename}:${start.line + 1}:${start.column + 1} Manual review required: migrate this ${argCount}-argument createEffect call. -Why: Solid 2 treats the second argument as options, not an initial value, and does not support a third positional argument. Legacy three-argument calls (callback, initialValue, options) must be restructured. -Guidance: Move the initial value to a default parameter on the compute callback, and move any legacy options that have Solid 2 equivalents into the second-argument options object. Verify that every legacy option is either mapped to a Solid 2 equivalent or intentionally dropped. Make and validate this migration yourself; this analyzer never edits or runs the target project. Stop without proposing a rewrite when the intent of each argument is unclear. Ask for the smallest focused test or runtime observation that makes the argument roles observable. Official migration guide: ${MIGRATION_GUIDE}`; - }); -} diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/destructured-require.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/destructured-require.fixture.tsx new file mode 100644 index 0000000..7b424e8 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/destructured-require.fixture.tsx @@ -0,0 +1,12 @@ +// fixture: destructured-require +const [require] = [null]; +require("solid-js/h"); + +function takesArray([require]) { + return require("solid-js/html"); +} + +const { require = null } = {}; +require("solid-js/universal"); + +void takesArray; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/escaped-specifiers.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/escaped-specifiers.fixture.tsx new file mode 100644 index 0000000..9b09327 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/escaped-specifiers.fixture.tsx @@ -0,0 +1,13 @@ +// fixture: escaped-specifiers +import { html as escapedHex } from "solid-js\x2fhtml"; +import { createRenderer as escapedUnicode } from "solid-js\u002funiversal"; +import { jsx as escapedCodePoint } from "solid-js\u{2f}jsx-runtime"; +import { jsxDEV as continued } from "solid-js/\ +jsx-dev-runtime"; +import { nearStore as escapedNearMiss } from "solid-js\\x2fstore"; + +void escapedHex; +void escapedUnicode; +void escapedCodePoint; +void continued; +void escapedNearMiss; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/negative-forms.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/negative-forms.fixture.tsx new file mode 100644 index 0000000..af55412 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/negative-forms.fixture.tsx @@ -0,0 +1,23 @@ +// fixture: negative-forms +import { nearH as prefixNearMiss } from "solid-js/h-extra"; +import { nearH as suffixNearMiss } from "vendor/solid-js/h"; +import { nearH as trailingSlashNearMiss } from "solid-js/h/"; +import { nearStore as escapedNearMiss } from "solid-js\\x2fstore"; +import { createStore } from "solid-js/store"; +import { render } from "solid-js/web"; +import migratedH from "@solidjs/h"; +export default "solid-js/h"; +const viaResolve = require.resolve("solid-js/html"); +const viaTemplate = import(`solid-js/universal`); +const url = new URL("solid-js/jsx-runtime", import.meta.url); + +void prefixNearMiss; +void suffixNearMiss; +void trailingSlashNearMiss; +void escapedNearMiss; +void createStore; +void render; +void migratedH; +void viaResolve; +void viaTemplate; +void url; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/no-matches.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/no-matches.fixture.tsx new file mode 100644 index 0000000..feb5176 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/no-matches.fixture.tsx @@ -0,0 +1,2 @@ +// fixture: no-matches +export {}; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/prototype-names.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/prototype-names.fixture.tsx new file mode 100644 index 0000000..2d8e888 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/prototype-names.fixture.tsx @@ -0,0 +1,14 @@ +// fixture: prototype-names +import constructorModule from "constructor"; +import toStringModule from "toString"; +import hasOwnPropertyModule from "hasOwnProperty"; +import valueOfModule from "valueOf"; +import protoModule from "__proto__"; +import h from "solid-js/h"; + +void constructorModule; +void toStringModule; +void hasOwnPropertyModule; +void valueOfModule; +void protoModule; +void h; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/re-exports.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/re-exports.fixture.tsx new file mode 100644 index 0000000..337cc64 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/re-exports.fixture.tsx @@ -0,0 +1,7 @@ +// fixture: re-exports +export { h } from "solid-js/h"; +export * from "solid-js/html"; +export { default as htmlDefault } from "solid-js/html"; +export { createRenderer as renderer, createUniversal as universal } from "solid-js/universal"; +export type { JSX } from "solid-js/jsx-runtime"; +export { jsxDEV } from "solid-js/jsx-dev-runtime"; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/relocations.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/relocations.fixture.tsx new file mode 100644 index 0000000..bfce02b --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/relocations.fixture.tsx @@ -0,0 +1,63 @@ +// fixture: relocations +import h from "solid-js/h"; +import html from "solid-js/html"; +import { createRenderer } from "solid-js/universal"; +import { jsx } from "solid-js/jsx-runtime"; +import { jsxDEV } from "solid-js/jsx-dev-runtime"; +// prettier-ignore +import { html as singleHtml } from 'solid-js/html'; +// prettier-ignore +import { createRenderer as singleRenderer } from 'solid-js/universal'; +import { html as escapedHex } from "solid-js\x2fhtml"; +import { createRenderer as escapedUnicode } from "solid-js\u002funiversal"; +import { jsx as escapedCodePoint } from "solid-js\u{2f}jsx-runtime"; +import { jsxDEV as continued } from "solid-js/\ +jsx-dev-runtime"; + +export { h as reexportedH } from "solid-js/h"; +export * from "solid-js/html"; + +const dynamicHtml = import("solid-js/html"); +declare const require: (name: string) => unknown; +const commonJsUniversal = require("solid-js/universal"); +type RuntimeTypes = import("solid-js/jsx-runtime").JSX; + +// Out-of-scope and already-migrated references that must remain byte-identical. +import { createStore as storeIsOutOfScope } from "solid-js/store"; +import { render as webIsOutOfScope } from "solid-js/web"; +import migratedH from "@solidjs/h"; +import migratedHtml from "@solidjs/html"; +import { createRenderer as migratedUniversal } from "@solidjs/universal"; +import { jsx as migratedRuntime } from "@solidjs/web/jsx-runtime"; +import { jsxDEV as migratedDevRuntime } from "@solidjs/web/jsx-dev-runtime"; +import { nearH as prefixNearMiss } from "solid-js/h-extra"; +import { nearH as suffixNearMiss } from "vendor/solid-js/h"; +import { nearH as trailingSlashNearMiss } from "solid-js/h/"; +import { nearStore as escapedNearMiss } from "solid-js\\x2fstore"; + +void h; +void html; +void createRenderer; +void jsx; +void jsxDEV; +void singleHtml; +void singleRenderer; +void escapedHex; +void escapedUnicode; +void escapedCodePoint; +void continued; +void reexportedH; +void dynamicHtml; +void commonJsUniversal; +void storeIsOutOfScope; +void webIsOutOfScope; +void migratedH; +void migratedHtml; +void migratedUniversal; +void migratedRuntime; +void migratedDevRuntime; +void prefixNearMiss; +void suffixNearMiss; +void trailingSlashNearMiss; +void escapedNearMiss; +type _RuntimeTypes = RuntimeTypes; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/runtime-forms.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/runtime-forms.fixture.tsx new file mode 100644 index 0000000..1a9ae3a --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/runtime-forms.fixture.tsx @@ -0,0 +1,28 @@ +// fixture: runtime-forms +const dynamicH = import("solid-js/h"); + +async function load() { + const dynamicHtml = await import("solid-js/html"); + return dynamicHtml; +} + +declare const require: (name: string) => unknown; +const requiredUniversal = require("solid-js/universal"); +type RuntimeTypes = import("solid-js/jsx-runtime").JSX; +type DevTypes = typeof import("solid-js/jsx-dev-runtime"); + +import { require as aliasRequire } from "./helper"; +function usesDefault(a = require) { + return a; +} +const { a = require } = { a: null }; +const localAlias = require; + +void dynamicH; +void load; +void requiredUniversal; +void aliasRequire; +void usesDefault; +void localAlias; +type _RuntimeTypes = RuntimeTypes; +type _DevTypes = DevTypes; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/shadowed-require.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/shadowed-require.fixture.tsx new file mode 100644 index 0000000..0e8cbe4 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/shadowed-require.fixture.tsx @@ -0,0 +1,17 @@ +// fixture: shadowed-require +function require(name: string): unknown { + return name; +} +const localFunction = require("solid-js/h"); + +const localVariable = (() => { + const require = (name: string) => name; + return require("solid-js/html"); +})(); + +const localParameter = ((require: (name: string) => unknown) => + require("solid-js/universal"))((name: string) => name); + +void localFunction; +void localVariable; +void localParameter; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/single-quotes.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/single-quotes.fixture.tsx new file mode 100644 index 0000000..132cae7 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/single-quotes.fixture.tsx @@ -0,0 +1,22 @@ +// fixture: single-quotes +import h from 'solid-js/h'; +import { render } from 'solid-js/html'; +import { createRenderer } from 'solid-js/universal'; +import { jsx } from 'solid-js/jsx-runtime'; +import { jsxDEV } from 'solid-js/jsx-dev-runtime'; +export { h as exportedH } from 'solid-js/h'; +export * from 'solid-js/html'; +const dynamicHtml = import('solid-js/html'); +declare const require: (name: string) => unknown; +const commonJsUniversal = require('solid-js/universal'); +type RuntimeTypes = import('solid-js/jsx-runtime').JSX; + +void h; +void render; +void createRenderer; +void jsx; +void jsxDEV; +void exportedH; +void dynamicHtml; +void commonJsUniversal; +type _RuntimeTypes = RuntimeTypes; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/static-imports.fixture.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/static-imports.fixture.tsx new file mode 100644 index 0000000..1b127c0 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/fixtures/static-imports.fixture.tsx @@ -0,0 +1,17 @@ +// fixture: static-imports +import h from "solid-js/h"; +import html, { render } from "solid-js/html"; +import { createRenderer as renderer } from "solid-js/universal"; +import { jsx, jsxs } from "solid-js/jsx-runtime"; +import { jsxDEV as dev } from "solid-js/jsx-dev-runtime"; +import * as hNamespace from "solid-js/h"; +import "solid-js/jsx-runtime"; + +void h; +void html; +void render; +void renderer; +void jsx; +void jsxs; +void dev; +void hNamespace; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.test.ts b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.test.ts new file mode 100644 index 0000000..26bf819 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.test.ts @@ -0,0 +1,180 @@ +import type { Codemod } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { + relocateLegacySubpaths, + TRANSFORM_MIGRATION_GUIDE, +} from "./legacy-subpath-relocation.ts"; +import { formatLegacySubpathRelocationGuidance } from "./report.ts"; + +type FixtureCase = { + relocations: ReadonlyArray<{ + location: string; + from: string; + to: string; + }>; + /** Exact post-transform file text, or null when the file must stay unchanged. */ + transformed: string | null; +}; + +const FIXTURE_CASES: Record = { + "relocations": { + relocations: [ + { location: "2:15", from: "solid-js/h", to: "@solidjs/h" }, + { location: "3:18", from: "solid-js/html", to: "@solidjs/html" }, + { location: "4:32", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "5:21", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "6:24", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + { location: "8:36", from: "solid-js/html", to: "@solidjs/html" }, + { location: "10:50", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "11:36", from: "solid-js/html", to: "@solidjs/html" }, + { location: "12:50", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "13:41", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "14:37", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + { location: "17:34", from: "solid-js/h", to: "@solidjs/h" }, + { location: "18:15", from: "solid-js/html", to: "@solidjs/html" }, + { location: "20:28", from: "solid-js/html", to: "@solidjs/html" }, + { location: "22:35", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "23:28", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + ], + transformed: "// fixture: relocations\nimport h from \"@solidjs/h\";\nimport html from \"@solidjs/html\";\nimport { createRenderer } from \"@solidjs/universal\";\nimport { jsx } from \"@solidjs/web/jsx-runtime\";\nimport { jsxDEV } from \"@solidjs/web/jsx-dev-runtime\";\n// prettier-ignore\nimport { html as singleHtml } from '@solidjs/html';\n// prettier-ignore\nimport { createRenderer as singleRenderer } from '@solidjs/universal';\nimport { html as escapedHex } from \"@solidjs/html\";\nimport { createRenderer as escapedUnicode } from \"@solidjs/universal\";\nimport { jsx as escapedCodePoint } from \"@solidjs/web/jsx-runtime\";\nimport { jsxDEV as continued } from \"@solidjs/web/jsx-dev-runtime\";\n\nexport { h as reexportedH } from \"@solidjs/h\";\nexport * from \"@solidjs/html\";\n\nconst dynamicHtml = import(\"@solidjs/html\");\ndeclare const require: (name: string) => unknown;\nconst commonJsUniversal = require(\"@solidjs/universal\");\ntype RuntimeTypes = import(\"@solidjs/web/jsx-runtime\").JSX;\n\n// Out-of-scope and already-migrated references that must remain byte-identical.\nimport { createStore as storeIsOutOfScope } from \"solid-js/store\";\nimport { render as webIsOutOfScope } from \"solid-js/web\";\nimport migratedH from \"@solidjs/h\";\nimport migratedHtml from \"@solidjs/html\";\nimport { createRenderer as migratedUniversal } from \"@solidjs/universal\";\nimport { jsx as migratedRuntime } from \"@solidjs/web/jsx-runtime\";\nimport { jsxDEV as migratedDevRuntime } from \"@solidjs/web/jsx-dev-runtime\";\nimport { nearH as prefixNearMiss } from \"solid-js/h-extra\";\nimport { nearH as suffixNearMiss } from \"vendor/solid-js/h\";\nimport { nearH as trailingSlashNearMiss } from \"solid-js/h/\";\nimport { nearStore as escapedNearMiss } from \"solid-js\\\\x2fstore\";\n\nvoid h;\nvoid html;\nvoid createRenderer;\nvoid jsx;\nvoid jsxDEV;\nvoid singleHtml;\nvoid singleRenderer;\nvoid escapedHex;\nvoid escapedUnicode;\nvoid escapedCodePoint;\nvoid continued;\nvoid reexportedH;\nvoid dynamicHtml;\nvoid commonJsUniversal;\nvoid storeIsOutOfScope;\nvoid webIsOutOfScope;\nvoid migratedH;\nvoid migratedHtml;\nvoid migratedUniversal;\nvoid migratedRuntime;\nvoid migratedDevRuntime;\nvoid prefixNearMiss;\nvoid suffixNearMiss;\nvoid trailingSlashNearMiss;\nvoid escapedNearMiss;\ntype _RuntimeTypes = RuntimeTypes;\n", + }, + "static-imports": { + relocations: [ + { location: "2:15", from: "solid-js/h", to: "@solidjs/h" }, + { location: "3:30", from: "solid-js/html", to: "@solidjs/html" }, + { location: "4:44", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "5:27", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "6:31", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + { location: "7:29", from: "solid-js/h", to: "@solidjs/h" }, + { location: "8:8", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + ], + transformed: "// fixture: static-imports\nimport h from \"@solidjs/h\";\nimport html, { render } from \"@solidjs/html\";\nimport { createRenderer as renderer } from \"@solidjs/universal\";\nimport { jsx, jsxs } from \"@solidjs/web/jsx-runtime\";\nimport { jsxDEV as dev } from \"@solidjs/web/jsx-dev-runtime\";\nimport * as hNamespace from \"@solidjs/h\";\nimport \"@solidjs/web/jsx-runtime\";\n\nvoid h;\nvoid html;\nvoid render;\nvoid renderer;\nvoid jsx;\nvoid jsxs;\nvoid dev;\nvoid hNamespace;\n", + }, + "single-quotes": { + relocations: [ + { location: "2:15", from: "solid-js/h", to: "@solidjs/h" }, + { location: "3:24", from: "solid-js/html", to: "@solidjs/html" }, + { location: "4:32", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "5:21", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "6:24", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + { location: "7:32", from: "solid-js/h", to: "@solidjs/h" }, + { location: "8:15", from: "solid-js/html", to: "@solidjs/html" }, + { location: "9:28", from: "solid-js/html", to: "@solidjs/html" }, + { location: "11:35", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "12:28", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + ], + transformed: "// fixture: single-quotes\nimport h from '@solidjs/h';\nimport { render } from '@solidjs/html';\nimport { createRenderer } from '@solidjs/universal';\nimport { jsx } from '@solidjs/web/jsx-runtime';\nimport { jsxDEV } from '@solidjs/web/jsx-dev-runtime';\nexport { h as exportedH } from '@solidjs/h';\nexport * from '@solidjs/html';\nconst dynamicHtml = import('@solidjs/html');\ndeclare const require: (name: string) => unknown;\nconst commonJsUniversal = require('@solidjs/universal');\ntype RuntimeTypes = import('@solidjs/web/jsx-runtime').JSX;\n\nvoid h;\nvoid render;\nvoid createRenderer;\nvoid jsx;\nvoid jsxDEV;\nvoid exportedH;\nvoid dynamicHtml;\nvoid commonJsUniversal;\ntype _RuntimeTypes = RuntimeTypes;\n", + }, + "re-exports": { + relocations: [ + { location: "2:19", from: "solid-js/h", to: "@solidjs/h" }, + { location: "3:15", from: "solid-js/html", to: "@solidjs/html" }, + { location: "4:40", from: "solid-js/html", to: "@solidjs/html" }, + { location: "5:74", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "6:26", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "7:24", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + ], + transformed: "// fixture: re-exports\nexport { h } from \"@solidjs/h\";\nexport * from \"@solidjs/html\";\nexport { default as htmlDefault } from \"@solidjs/html\";\nexport { createRenderer as renderer, createUniversal as universal } from \"@solidjs/universal\";\nexport type { JSX } from \"@solidjs/web/jsx-runtime\";\nexport { jsxDEV } from \"@solidjs/web/jsx-dev-runtime\";\n", + }, + "runtime-forms": { + relocations: [ + { location: "2:25", from: "solid-js/h", to: "@solidjs/h" }, + { location: "5:36", from: "solid-js/html", to: "@solidjs/html" }, + { location: "10:35", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "11:28", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "12:31", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + ], + transformed: "// fixture: runtime-forms\nconst dynamicH = import(\"@solidjs/h\");\n\nasync function load() {\n const dynamicHtml = await import(\"@solidjs/html\");\n return dynamicHtml;\n}\n\ndeclare const require: (name: string) => unknown;\nconst requiredUniversal = require(\"@solidjs/universal\");\ntype RuntimeTypes = import(\"@solidjs/web/jsx-runtime\").JSX;\ntype DevTypes = typeof import(\"@solidjs/web/jsx-dev-runtime\");\n\nimport { require as aliasRequire } from \"./helper\";\nfunction usesDefault(a = require) {\n return a;\n}\nconst { a = require } = { a: null };\nconst localAlias = require;\n\nvoid dynamicH;\nvoid load;\nvoid requiredUniversal;\nvoid aliasRequire;\nvoid usesDefault;\nvoid localAlias;\ntype _RuntimeTypes = RuntimeTypes;\ntype _DevTypes = DevTypes;\n", + }, + "negative-forms": { + relocations: [], + transformed: null, + }, + "shadowed-require": { + relocations: [], + transformed: null, + }, + "destructured-require": { + relocations: [], + transformed: null, + }, + "prototype-names": { + relocations: [ + { location: "7:15", from: "solid-js/h", to: "@solidjs/h" }, + ], + transformed: "// fixture: prototype-names\nimport constructorModule from \"constructor\";\nimport toStringModule from \"toString\";\nimport hasOwnPropertyModule from \"hasOwnProperty\";\nimport valueOfModule from \"valueOf\";\nimport protoModule from \"__proto__\";\nimport h from \"@solidjs/h\";\n\nvoid constructorModule;\nvoid toStringModule;\nvoid hasOwnPropertyModule;\nvoid valueOfModule;\nvoid protoModule;\nvoid h;\n", + }, + "escaped-specifiers": { + relocations: [ + { location: "2:36", from: "solid-js/html", to: "@solidjs/html" }, + { location: "3:50", from: "solid-js/universal", to: "@solidjs/universal" }, + { location: "4:41", from: "solid-js/jsx-runtime", to: "@solidjs/web/jsx-runtime" }, + { location: "5:37", from: "solid-js/jsx-dev-runtime", to: "@solidjs/web/jsx-dev-runtime" }, + ], + transformed: "// fixture: escaped-specifiers\nimport { html as escapedHex } from \"@solidjs/html\";\nimport { createRenderer as escapedUnicode } from \"@solidjs/universal\";\nimport { jsx as escapedCodePoint } from \"@solidjs/web/jsx-runtime\";\nimport { jsxDEV as continued } from \"@solidjs/web/jsx-dev-runtime\";\nimport { nearStore as escapedNearMiss } from \"solid-js\\\\x2fstore\";\n\nvoid escapedHex;\nvoid escapedUnicode;\nvoid escapedCodePoint;\nvoid continued;\nvoid escapedNearMiss;\n", + }, + "no-matches": { + relocations: [], + transformed: null, + }, +}; + +const testLegacySubpathRelocation: Codemod = async (root) => { + const rootNode = root.root(); + const filename = root.relativeFilename().replaceAll("\\", "/"); + const source = root.source(); + const marker = /\/\/ fixture: ([a-z0-9-]+)/.exec(source)?.[1]; + if (!marker) { + throw new Error( + "fixture is missing a `// fixture: ` marker on its first line", + ); + } + + const fixtureCase = FIXTURE_CASES[marker]; + if (!fixtureCase) { + throw new Error(`unknown fixture marker: ${marker}`); + } + + const { edits, report } = relocateLegacySubpaths(rootNode, filename); + + const expected = fixtureCase.relocations + .map(({ location, from, to }) => + `${filename}:${location} Relocate ${from} to ${to}. Official migration guide: ${TRANSFORM_MIGRATION_GUIDE}`, + ) + .sort(); + + if (report.findings.some((finding) => "guidance" in finding || !finding.summary || !finding.reason || finding.nextSteps.length === 0 || !finding.officialGuideUrl)) { + throw new Error("relocation report must expose structured guidance fields only"); + } + if (report.findings.some((finding) => finding.snippet.matchStartLine !== finding.line || finding.snippet.matchEndLine < finding.snippet.matchStartLine || finding.snippet.matchEndLine > finding.snippet.endLine)) { + throw new Error("relocation snippets must carry AST match line ranges"); + } + const actual = report.findings.map(formatLegacySubpathRelocationGuidance).sort(); + if (actual.join("\n") !== expected.join("\n")) { + throw new Error( + `unexpected relocation report for ${marker}:\n${actual.join("\n")}\nExpected:\n${expected.join("\n")}`, + ); + } + + for (const finding of report.findings) { + if (!finding.snippet.text.includes("solid-js")) throw new Error("relocation snippet must contain the matched module"); + if (finding.snippet.startLine !== Math.max(1, finding.line - 1)) { + throw new Error(`snippet must start one complete line before ${finding.line}`); + } + if (finding.snippet.endLine < finding.line || finding.snippet.text.split("\n").length !== finding.snippet.endLine - finding.snippet.startLine + 1) { + throw new Error(`snippet must include the full match and complete line bounds at ${finding.line}`); + } + } + + const transformed = rootNode.commitEdits(edits); + const expectedTransformed = fixtureCase.transformed ?? source; + if (transformed !== expectedTransformed) { + throw new Error( + `unexpected transformed output for ${marker}:\n${transformed}\nExpected:\n${expectedTransformed}`, + ); + } + + return null; +}; + +export default testLegacySubpathRelocation; diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.ts b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.ts new file mode 100644 index 0000000..b6ffec8 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.ts @@ -0,0 +1,209 @@ +import type { Edit, SgNode } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { findModuleReferences, sourceSnippet } from "../../../../shared/analysis.ts"; +import type { LegacySubpathRelocationFinding, LegacySubpathRelocationReport } from "./report.ts"; + +export const SOLID_SOURCE_COMMIT = + "ff4d3c4479163fbdd3327f5b22d0c3ea7bd1a2c5"; + +export const TRANSFORM_MIGRATION_GUIDE = `https://github.com/solidjs/solid/blob/${SOLID_SOURCE_COMMIT}/documentation/solid-2.0/MIGRATION.md#imports-where-things-live-now`; + +/** + * The deterministic, semantics-preserving subset of the Solid 2 import-path + * moves. Every entry is a pure package relocation: the module source changes + * while imported bindings, import form, and quote style are preserved. No + * entry here has a removed, renamed, or behaviorally changed export, so each + * rewrite is safe without binding-level review. + */ +export const LEGACY_SUBPATH_RELOCATIONS: Readonly> = { + "solid-js/h": "@solidjs/h", + "solid-js/html": "@solidjs/html", + "solid-js/universal": "@solidjs/universal", + "solid-js/jsx-runtime": "@solidjs/web/jsx-runtime", + "solid-js/jsx-dev-runtime": "@solidjs/web/jsx-dev-runtime", +}; + +export type LegacySubpathRelocationResult = { + readonly edits: Edit[]; + readonly report: LegacySubpathRelocationReport; +}; + +/** + * Looks up a relocation target using an own-property check. A plain + * `LEGACY_SUBPATH_RELOCATIONS[moduleName]` read would traverse the object's + * prototype chain, so bare specifiers such as "constructor", "toString", or + * "__proto__" would resolve to inherited members and be rewritten into + * garbage. Those specifiers are ordinary module names and must stay intact. + */ +function relocationTarget(moduleName: string): string | undefined { + if (!Object.hasOwn(LEGACY_SUBPATH_RELOCATIONS, moduleName)) return undefined; + return LEGACY_SUBPATH_RELOCATIONS[moduleName]; +} + +/** + * Only the string that follows the `from` keyword is a re-export's module + * source. `export default "solid-js/h"` also places a string directly under + * `export_statement`, but that string is the exported value, not a module + * reference, so it must never be rewritten. + */ +function isReExportSource(source: SgNode): boolean { + const statement = source.parent(); + if (!statement || statement.kind() !== "export_statement") return false; + return statement.children().some((child) => child.kind() === "from"); +} + +/** + * Kinds of syntax nodes that can directly hold a `require` binding. For the + * position-sensitive kinds, `isShadowBinding` narrows the match to the actual + * binding slot so adjacent references never count. + */ +const REQUIRE_BINDING_PARENT_KINDS = new Set([ + "function_declaration", + "class_declaration", + "variable_declarator", + "required_parameter", + "optional_parameter", + "rest_pattern", + "array_pattern", + "object_pattern", + "pair_pattern", + "import_clause", + "namespace_import", + "import_specifier", + "catch_clause", + "assignment_pattern", + "object_assignment_pattern", +]); + +function samePosition(left: SgNode, right: SgNode): boolean { + const a = left.range().start; + const b = right.range().start; + return a.line === b.line && a.column === b.column; +} + +/** + * True when the identifier occupies the binding slot of its parent node and + * not merely a reference next to it. This keeps the scan precise in both + * directions: `import { require as r }`, `function f(a = require)`, and + * `const alias = require` are references (the local binding is `r`, `a`, and + * `alias`), while `const [require] = arr`, `const { require = 1 } = obj`, and + * `function g(require = 1)` all bind `require` and must suppress relocation + * of bare `require(...)` calls. + */ +function isShadowBinding(node: SgNode): boolean { + const parent = node.parent(); + if (!parent || !REQUIRE_BINDING_PARENT_KINDS.has(parent.kind())) { + return false; + } + switch (parent.kind()) { + case "import_specifier": { + const alias = parent.field("alias"); + return ( + alias === null || + alias.text() === "require" || + samePosition(alias, node) + ); + } + case "required_parameter": + case "optional_parameter": { + const pattern = parent.field("pattern"); + return pattern !== null && samePosition(pattern, node); + } + case "assignment_pattern": + case "object_assignment_pattern": { + const left = parent.field("left"); + return left !== null && samePosition(left, node); + } + case "pair_pattern": { + const value = parent.field("value"); + return value !== null && samePosition(value, node); + } + case "function_declaration": + case "class_declaration": + case "variable_declarator": { + const name = parent.field("name"); + return name !== null && samePosition(name, node); + } + default: + return true; + } +} + +/** + * Bare `require(...)` calls are module references only when `require` is the + * ambient CommonJS require. The transform workflow has no semantic provider, + * so shadowing is detected syntactically: if the file declares its own + * `require` binding anywhere (function/class declaration, variable, + * destructuring pattern, parameter, import, catch parameter), every bare + * `require(...)` call in that file is conservatively left untouched. + * `declare const require` and `declare function require` describe the global + * require and are exempted. Binding slots are matched position-precisely, so + * `import { require as r }`, `function f(a = require)`, and + * `const alias = require` never count as shadows. + */ +function hasShadowingRequireBinding(rootNode: SgNode): boolean { + const kinds = [ + "identifier", + "type_identifier", + "shorthand_property_identifier_pattern", + ] as const; + for (const kind of kinds) { + for (const node of rootNode.findAll({ + rule: { kind, regex: "^require$" }, + })) { + if ( + node.ancestors().some( + (ancestor) => ancestor.kind() === "ambient_declaration", + ) + ) { + continue; + } + if (isShadowBinding(node)) return true; + } + } + return false; +} + +/** + * Maps every legacy Solid subpath module reference in `rootNode` to a + * deterministic edit plus a human-readable per-edit report. The caller + * commits the edits and persists the reports; this rule performs no I/O. + */ +export function relocateLegacySubpaths( + rootNode: SgNode, + filename: string, +): LegacySubpathRelocationResult { + const shadowedRequire = hasShadowingRequireBinding(rootNode); + const relocations = findModuleReferences(rootNode) + .map(({ source, moduleName, form }) => { + const replacement = relocationTarget(moduleName); + if (replacement === undefined) return null; + if (form === "re-export" && !isReExportSource(source)) return null; + if (form === "require" && shadowedRequire) return null; + const start = source.range().start; + const quote = source.text()[0]; + const finding: LegacySubpathRelocationFinding = { + filename, + line: start.line + 1, + column: start.column + 1, + form, + sourceModule: moduleName, + replacementModule: replacement, + summary: `Relocate ${moduleName} to ${replacement}.`, + reason: "Solid 2 publishes this runtime from a dedicated package instead of the legacy solid-js subpath.", + nextSteps: ["Replace only the module source and preserve the import form, bindings, and quote style."], + cautions: ["Do not combine this deterministic path relocation with removed or renamed binding migrations."], + validation: ["Run the target project's focused typecheck and tests after applying the proposed edit."], + officialGuideUrl: TRANSFORM_MIGRATION_GUIDE, + snippet: sourceSnippet(source), + }; + return { edit: source.replace(`${quote}${replacement}${quote}`), finding }; + }) + .filter( + (entry): entry is { edit: Edit; finding: LegacySubpathRelocationFinding } => entry !== null, + ); + return { + edits: relocations.map(({ edit }) => edit), + report: { findings: relocations.map(({ finding }) => finding) }, + }; +} diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/report.ts b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/report.ts new file mode 100644 index 0000000..4abc508 --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/report.ts @@ -0,0 +1,24 @@ +import { defineRuleReportAggregator, type SourceSnippet } from "../../../../shared/report.ts"; +export const LEGACY_SUBPATH_RELOCATION_RULE_ID = "transformation/imports/legacy-subpath-relocation"; +export const LEGACY_SUBPATH_RELOCATION_RULE_ROUTE = "imports/legacy-subpath-relocation"; +export type RelocationForm = "import" | "re-export" | "dynamic-import" | "require"; +export type LegacySubpathRelocationFinding = { + readonly filename: string; readonly line: number; readonly column: number; + readonly form: RelocationForm; readonly sourceModule: string; readonly replacementModule: string; + readonly summary: string; readonly reason: string; + readonly nextSteps: readonly string[]; readonly cautions: readonly string[]; + readonly validation: readonly string[]; readonly officialGuideUrl: string; + readonly snippet: SourceSnippet; +}; +export type LegacySubpathRelocationReport = { readonly findings: readonly LegacySubpathRelocationFinding[] }; +export function formatLegacySubpathRelocationGuidance(finding: LegacySubpathRelocationFinding): string { + return `${finding.filename}:${finding.line}:${finding.column} ${finding.summary} Official migration guide: ${finding.officialGuideUrl}`; +} +export const legacySubpathRelocationReportAggregator = defineRuleReportAggregator({ + id: LEGACY_SUBPATH_RELOCATION_RULE_ID, emptyReport: () => ({ findings: [] }), + merge: (projectReport, nextReport) => ({ findings: [...projectReport.findings, ...nextReport.findings].sort(compareFindings) }), +}); +function compareFindings(left: LegacySubpathRelocationFinding, right: LegacySubpathRelocationFinding): number { + const filenameOrder = left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0; + return filenameOrder || left.line - right.line || left.column - right.column; +} diff --git a/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/ui.tsx b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/ui.tsx new file mode 100644 index 0000000..311907e --- /dev/null +++ b/codemods/solid-migration-assistant/rules/transformations/imports/legacy-subpath-relocation/ui.tsx @@ -0,0 +1,43 @@ +import { GuidanceSections, RuleFindings } from "../../../../dashboard/finding-ui.tsx"; +import { formatFindingLocation } from "../../../../dashboard/finding-model.ts"; +import { defineRuleSlice } from "../../../../shared/report.ts"; +import { + LEGACY_SUBPATH_RELOCATION_RULE_ID, + LEGACY_SUBPATH_RELOCATION_RULE_ROUTE, + type LegacySubpathRelocationReport, +} from "./report.ts"; + +export const legacySubpathRelocationSlice = + defineRuleSlice({ + id: LEGACY_SUBPATH_RELOCATION_RULE_ID, + route: LEGACY_SUBPATH_RELOCATION_RULE_ROUTE, + title: "Legacy subpath relocations", + domain: "Imports", + kind: "transformation", + Summary: (props) =>

{props.report.findings.length} safe import-path edits proposed

, + Detail: (props) => ( + <> +

This analyzer run only proposes these edits; it never applies them.

+ ({ + filename: finding.filename, + location: formatFindingLocation(finding.filename, finding.line, finding.column), + label: <>{finding.sourceModule}{finding.replacementModule} ({finding.form}), + snippet: finding.snippet, + editorTarget: { + analyzedTargetRoot: props.run.analyzedTargetRoot, + filename: finding.filename, + line: finding.line, + column: finding.column, + }, + details: , + }))} /> + + ), + }); diff --git a/codemods/solid-migration-assistant/scripts/analyze.ts b/codemods/solid-migration-assistant/scripts/analyze.ts index b34b1de..c2f81fc 100644 --- a/codemods/solid-migration-assistant/scripts/analyze.ts +++ b/codemods/solid-migration-assistant/scripts/analyze.ts @@ -1,54 +1,61 @@ import type { Codemod, SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; import { acquireLock, getState, setState } from "codemod:workflow"; -import { analyzeBeta32SubpathImports } from "../rules/imports/beta32-subpaths/beta32-subpaths.ts"; -import { analyzeWebImport } from "../rules/imports/web-import/web-import.ts"; -import { analyzeJsxClassListAttributes } from "../rules/jsx/class-list/class-list.ts"; -import { analyzeJsxComponentRenames } from "../rules/jsx/component-renames/component-renames.ts"; -import { analyzeOnMount } from "../rules/lifecycle/on-mount/on-mount.ts"; -import { analyzeMergeProps } from "../rules/props/merge-props/merge-props.ts"; -import { analyzeSplitProps } from "../rules/props/split-props/split-props.ts"; -import { analyzeCreateComputed } from "../rules/reactivity/create-computed/create-computed.ts"; -import { analyzeCreateEffect } from "../rules/reactivity/create-effect/create-effect.ts"; -import { analyzeCreateMemo } from "../rules/reactivity/create-memo/create-memo.ts"; +import { analyzeBeta32SubpathImports } from "../rules/analysis/imports/beta32-subpaths/beta32-subpaths.ts"; +import { analyzeWebImport } from "../rules/analysis/imports/web-import/web-import.ts"; +import { webImportReportAggregator } from "../rules/analysis/imports/web-import/report.ts"; +import { analyzeJsxClassListAttributes } from "../rules/analysis/jsx/class-list/class-list.ts"; +import { analyzeJsxComponentRenames } from "../rules/analysis/jsx/component-renames/component-renames.ts"; +import { componentRenamesReportAggregator } from "../rules/analysis/jsx/component-renames/report.ts"; +import { analyzeOnMount } from "../rules/analysis/lifecycle/on-mount/on-mount.ts"; +import { analyzeMergeProps } from "../rules/analysis/props/merge-props/merge-props.ts"; +import { analyzeSplitProps } from "../rules/analysis/props/split-props/split-props.ts"; +import { analyzeCreateComputed } from "../rules/analysis/reactivity/create-computed/create-computed.ts"; +import { analyzeCreateEffect } from "../rules/analysis/reactivity/create-effect/create-effect.ts"; +import { createEffectReportAggregator } from "../rules/analysis/reactivity/create-effect/report.ts"; +import { analyzeCreateMemo } from "../rules/analysis/reactivity/create-memo/create-memo.ts"; import { analyzeCreateMutable, analyzeModifyMutable, -} from "../rules/store/mutable/mutable.ts"; -import { analyzeProduce } from "../rules/store/produce/produce.ts"; -import { analyzeUnwrap } from "../rules/store/unwrap/unwrap.ts"; -import { analyzeBatch } from "../rules/reactivity/batch/batch.ts"; -import { analyzeOnHelper } from "../rules/reactivity/on-helper/on-helper.ts"; -import { analyzeCreateResource } from "../rules/reactivity/create-resource/create-resource.ts"; -import { analyzeOnCleanup } from "../rules/lifecycle/on-cleanup/on-cleanup.ts"; +} from "../rules/analysis/store/mutable/mutable.ts"; +import { analyzeProduce } from "../rules/analysis/store/produce/produce.ts"; +import { analyzeUnwrap } from "../rules/analysis/store/unwrap/unwrap.ts"; +import { analyzeBatch } from "../rules/analysis/reactivity/batch/batch.ts"; +import { analyzeOnHelper } from "../rules/analysis/reactivity/on-helper/on-helper.ts"; +import { analyzeCreateResource } from "../rules/analysis/reactivity/create-resource/create-resource.ts"; +import { analyzeOnCleanup } from "../rules/analysis/lifecycle/on-cleanup/on-cleanup.ts"; import { analyzeOnError, analyzeCatchError, analyzeResetErrorBoundaries, -} from "../rules/reactivity/error-handling/error-handling.ts"; +} from "../rules/analysis/reactivity/error-handling/error-handling.ts"; import { analyzeStartTransition, analyzeUseTransition, analyzeCreateDeferred, -} from "../rules/reactivity/transition-apis/transition-apis.ts"; -import { analyzeCreateSelector } from "../rules/reactivity/create-selector/create-selector.ts"; -import { analyzeIndexArray } from "../rules/reactivity/index-array/index-array.ts"; -import { analyzeCreateDynamic } from "../rules/reactivity/create-dynamic/create-dynamic.ts"; +} from "../rules/analysis/reactivity/transition-apis/transition-apis.ts"; +import { analyzeCreateSelector } from "../rules/analysis/reactivity/create-selector/create-selector.ts"; +import { analyzeIndexArray } from "../rules/analysis/reactivity/index-array/index-array.ts"; +import { analyzeCreateDynamic } from "../rules/analysis/reactivity/create-dynamic/create-dynamic.ts"; import { analyzeFrom, analyzeObservable, -} from "../rules/reactivity/from-observable/from-observable.ts"; +} from "../rules/analysis/reactivity/from-observable/from-observable.ts"; import { analyzeEqualFn, analyzeGetListener, analyzeWriteSignal, analyzeEnableScheduling, -} from "../rules/reactivity/utility-renames/utility-renames.ts"; -import { analyzeDomAttrNamespaces } from "../rules/jsx/dom-attr-namespaces/dom-attr-namespaces.ts"; -import { analyzeDomEventNamespaces } from "../rules/jsx/dom-event-namespaces/dom-event-namespaces.ts"; -import { analyzeDomUseDirective } from "../rules/jsx/dom-use-directive/dom-use-directive.ts"; -import { analyzeContextProvider } from "../rules/jsx/context-provider/context-provider.ts"; +} from "../rules/analysis/reactivity/utility-renames/utility-renames.ts"; +import { analyzeDomAttrNamespaces } from "../rules/analysis/jsx/dom-attr-namespaces/dom-attr-namespaces.ts"; +import { analyzeDomEventNamespaces } from "../rules/analysis/jsx/dom-event-namespaces/dom-event-namespaces.ts"; +import { analyzeDomUseDirective } from "../rules/analysis/jsx/dom-use-directive/dom-use-directive.ts"; +import { analyzeContextProvider } from "../rules/analysis/jsx/context-provider/context-provider.ts"; +import { relocateLegacySubpaths } from "../rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.ts"; +import { legacySubpathRelocationReportAggregator } from "../rules/transformations/imports/legacy-subpath-relocation/report.ts"; import { ANALYSIS_STATE_KEY } from "../shared/analysis.ts"; +import { aggregateRuleReports, DASHBOARD_REPORT_STATE_KEY } from "../shared/report.ts"; +import type { JsonValue } from "../shared/report.ts"; type Analyzer = ( rootNode: SgNode, @@ -57,14 +64,11 @@ type Analyzer = ( const analyzers: Analyzer[] = [ analyzeBeta32SubpathImports, - analyzeWebImport, analyzeJsxClassListAttributes, - analyzeJsxComponentRenames, analyzeOnMount, analyzeMergeProps, analyzeSplitProps, analyzeCreateComputed, - analyzeCreateEffect, analyzeCreateMemo, analyzeCreateMutable, analyzeModifyMutable, @@ -100,12 +104,32 @@ const analyze: Codemod = async (root) => { const context = { filename: root.relativeFilename().replaceAll("\\", "/"), }; - const guidance = analyzers.flatMap((analyzer) => analyzer(rootNode, context)); + const webImport = analyzeWebImport(rootNode, context); + const createEffect = analyzeCreateEffect(rootNode, context); + const componentRenames = analyzeJsxComponentRenames(rootNode, context); + const relocationPreview = relocateLegacySubpaths(rootNode, context.filename); + const guidance = [ + ...analyzers.flatMap((analyzer) => analyzer(rootNode, context)), + ...webImport.guidance, + ...createEffect.guidance, + ...componentRenames.guidance, + ]; const release = acquireLock(ANALYSIS_STATE_KEY); try { const accumulated = getState(ANALYSIS_STATE_KEY) ?? []; setState(ANALYSIS_STATE_KEY, [...new Set([...accumulated, ...guidance])]); + const reports = + getState>(DASHBOARD_REPORT_STATE_KEY) ?? {}; + setState( + DASHBOARD_REPORT_STATE_KEY, + aggregateRuleReports(reports, [ + [webImportReportAggregator, webImport.report], + [createEffectReportAggregator, createEffect.report], + [componentRenamesReportAggregator, componentRenames.report], + [legacySubpathRelocationReportAggregator, relocationPreview.report], + ]), + ); } finally { release(); } diff --git a/codemods/solid-migration-assistant/scripts/emit-report.ts b/codemods/solid-migration-assistant/scripts/emit-report.ts new file mode 100644 index 0000000..6174487 --- /dev/null +++ b/codemods/solid-migration-assistant/scripts/emit-report.ts @@ -0,0 +1,46 @@ +import type { Codemod } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { acquireLock, getState, setState } from "codemod:workflow"; +import { ANALYSIS_STATE_KEY } from "../shared/analysis.ts"; +import { TRANSFORM_REPORT_STATE_KEY } from "../shared/transform.ts"; +import { DASHBOARD_REPORT_STATE_KEY, REPORT_SCHEMA_VERSION } from "../shared/report.ts"; +import type { JsonValue } from "../shared/report.ts"; + +const EMITTED_STATE_KEY = "solid-migration-assistant-report-emitted"; + +const emitReport: Codemod = async () => { + const release = acquireLock(EMITTED_STATE_KEY); + try { + if (getState(EMITTED_STATE_KEY)) return null; + + const guidance = [ + ...new Set(getState(ANALYSIS_STATE_KEY) ?? []), + ]; + const report = [ + ...new Set(getState(TRANSFORM_REPORT_STATE_KEY) ?? []), + ]; + setState(EMITTED_STATE_KEY, true, false); + + const blocks = [ + ...(guidance.length > 0 ? [guidance.sort().join("\n\n")] : []), + ...(report.length > 0 ? [report.sort().join("\n")] : []), + ]; + if (blocks.length > 0) { + console.log(blocks.join("\n\n")); + } + + if (process.env.SOLID_MIGRATION_REPORT_MODE === "1") { + const reports = + getState>(DASHBOARD_REPORT_STATE_KEY) ?? {}; + console.log( + `__SOLID_MIGRATION_REPORT_DATA__${JSON.stringify({ schemaVersion: REPORT_SCHEMA_VERSION, reports })}`, + ); + } + } finally { + release(); + } + + return null; +}; + +export default emitReport; diff --git a/codemods/solid-migration-assistant/scripts/emit.ts b/codemods/solid-migration-assistant/scripts/emit.ts deleted file mode 100644 index cb2102b..0000000 --- a/codemods/solid-migration-assistant/scripts/emit.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Codemod } from "codemod:ast-grep"; -import type TSX from "codemod:ast-grep/langs/tsx"; -import { acquireLock, getState, setState } from "codemod:workflow"; -import { ANALYSIS_STATE_KEY } from "../shared/analysis.ts"; - -const EMITTED_STATE_KEY = "solid-migration-assistant-guidance-emitted"; - -const emit: Codemod = async () => { - const release = acquireLock(EMITTED_STATE_KEY); - try { - if (getState(EMITTED_STATE_KEY)) return null; - - const guidance = [ - ...new Set(getState(ANALYSIS_STATE_KEY) ?? []), - ].sort(); - setState(EMITTED_STATE_KEY, true, false); - - if (guidance.length > 0) { - console.log(guidance.join("\n\n")); - } - } finally { - release(); - } - - return null; -}; - -export default emit; diff --git a/codemods/solid-migration-assistant/scripts/transform.ts b/codemods/solid-migration-assistant/scripts/transform.ts new file mode 100644 index 0000000..0d19dcc --- /dev/null +++ b/codemods/solid-migration-assistant/scripts/transform.ts @@ -0,0 +1,28 @@ +import type { Codemod } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { acquireLock, getState, setState } from "codemod:workflow"; +import { relocateLegacySubpaths } from "../rules/transformations/imports/legacy-subpath-relocation/legacy-subpath-relocation.ts"; +import { formatLegacySubpathRelocationGuidance } from "../rules/transformations/imports/legacy-subpath-relocation/report.ts"; +import { TRANSFORM_REPORT_STATE_KEY } from "../shared/transform.ts"; + +const transform: Codemod = async (root) => { + const rootNode = root.root(); + const filename = root.relativeFilename().replaceAll("\\", "/"); + const { edits, report } = relocateLegacySubpaths(rootNode, filename); + + if (edits.length === 0) return null; + + const release = acquireLock(TRANSFORM_REPORT_STATE_KEY); + try { + const accumulated = getState(TRANSFORM_REPORT_STATE_KEY) ?? []; + setState(TRANSFORM_REPORT_STATE_KEY, [ + ...new Set([...accumulated, ...report.findings.map(formatLegacySubpathRelocationGuidance)]), + ]); + } finally { + release(); + } + + return rootNode.commitEdits(edits); +}; + +export default transform; diff --git a/codemods/solid-migration-assistant/shared/analysis.ts b/codemods/solid-migration-assistant/shared/analysis.ts index c2b1593..ada2ec9 100644 --- a/codemods/solid-migration-assistant/shared/analysis.ts +++ b/codemods/solid-migration-assistant/shared/analysis.ts @@ -1,5 +1,6 @@ import type { SgNode } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; +import type { SourceSnippet } from "./report.ts"; export const ANALYSIS_STATE_KEY = "solid-migration-assistant-guidance"; @@ -275,3 +276,26 @@ export function findModuleReferences( return matches; } + + +/** Includes the complete matched line range and one complete line of context on each side. */ +export function sourceSnippet(node: SgNode): SourceSnippet { + const fileRoot = [node, ...node.ancestors()].reduce((largest, candidate) => + candidate.text().length > largest.text().length ? candidate : largest, + ); + const lines = fileRoot.text().split(/\r?\n/); + const range = node.range(); + const lastMatchedLine = + range.end.column === 0 && range.end.line > range.start.line + ? range.end.line - 1 + : range.end.line; + const firstLine = Math.max(0, range.start.line - 1); + const lastLine = Math.min(lines.length - 1, lastMatchedLine + 1); + return { + startLine: firstLine + 1, + endLine: lastLine + 1, + matchStartLine: range.start.line + 1, + matchEndLine: lastMatchedLine + 1, + text: lines.slice(firstLine, lastLine + 1).join("\n"), + }; +} diff --git a/codemods/solid-migration-assistant/shared/report-artifact.mjs b/codemods/solid-migration-assistant/shared/report-artifact.mjs new file mode 100644 index 0000000..0d02183 --- /dev/null +++ b/codemods/solid-migration-assistant/shared/report-artifact.mjs @@ -0,0 +1,62 @@ +import { + linkSync, + mkdtempSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; + +const EMPTY_REPORT = '{"schemaVersion":1,"reports":{}}'; + +export function renderReportHtml(template, reportData, { analyzedTargetRoot } = {}) { + const workflowValue = JSON.parse(reportData); + assertWorkflowEnvelope(workflowValue); + if (!isAbsoluteTargetRoot(analyzedTargetRoot)) { + throw new Error("report generation requires an absolute analyzed target root"); + } + const value = { ...workflowValue, run: { analyzedTargetRoot } }; + const embedded = JSON.stringify(value) + .replaceAll("&", "\\u0026") + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("
", "\\u2028") + .replaceAll("
", "\\u2029"); + const markerCount = template.split(EMPTY_REPORT).length - 1; + if (markerCount !== 1) { + throw new Error(`dashboard template must contain exactly one empty report marker; found ${markerCount}`); + } + return template.replace(EMPTY_REPORT, embedded); +} + +export function writeReportAtomically(path, html, { force = false } = {}) { + const parent = dirname(path); + const temporaryDirectory = mkdtempSync(join(parent, ".solid-migration-report-")); + const temporaryPath = join(temporaryDirectory, "report.html"); + try { + writeFileSync(temporaryPath, html, { encoding: "utf8", flag: "wx", mode: 0o600 }); + if (force) { + renameSync(temporaryPath, path); + } else { + linkSync(temporaryPath, path); + unlinkSync(temporaryPath); + } + } finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + } +} + +function assertWorkflowEnvelope(value) { + if (!isPlainObject(value) || value.schemaVersion !== 1 || !isPlainObject(value.reports)) { + throw new Error("workflow returned an invalid report envelope"); + } +} +function isAbsoluteTargetRoot(value) { + return typeof value === "string" && (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)); +} +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/codemods/solid-migration-assistant/shared/report.ts b/codemods/solid-migration-assistant/shared/report.ts new file mode 100644 index 0000000..d3b7565 --- /dev/null +++ b/codemods/solid-migration-assistant/shared/report.ts @@ -0,0 +1,237 @@ +import type { JSX } from "@solidjs/web"; + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = + | JsonPrimitive + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export const REPORT_SCHEMA_VERSION = 1 as const; +export const EMBEDDED_REPORT_ID = "solid-migration-report-data"; +export const DASHBOARD_REPORT_STATE_KEY = "solid-migration-assistant-dashboard-reports"; + +export type SourceSnippet = { + readonly startLine: number; + readonly endLine: number; + readonly matchStartLine: number; + readonly matchEndLine: number; + readonly text: string; +}; + +export type AnalysisRuleResult = { + readonly guidance: readonly string[]; + readonly report: TReport; +}; + +/** A single immutable run. History policy intentionally lives outside this seam. */ +export type ReportRunMetadata = { + readonly analyzedTargetRoot: string; +}; + +export type ReportEnvelope = { + readonly schemaVersion: typeof REPORT_SCHEMA_VERSION; + readonly run: ReportRunMetadata; + readonly reports: Readonly>; +}; + +export type RuleReportAggregator = { + readonly id: string; + readonly emptyReport: () => JsonValue; + readonly merge: (projectReport: JsonValue, nextReport: JsonValue) => JsonValue; +}; + +export function defineRuleReportAggregator(definition: { + readonly id: string; + readonly emptyReport: () => TReport; + readonly merge: (projectReport: TReport, nextReport: TReport) => TReport; +}): RuleReportAggregator { + return { + id: definition.id, + emptyReport: definition.emptyReport, + merge: (projectReport, nextReport) => + definition.merge(projectReport as TReport, nextReport as TReport), + }; +} + +export function aggregateRuleReports( + existing: Readonly>, + contributions: readonly (readonly [RuleReportAggregator, JsonValue])[], +): Readonly> { + const reports = { ...existing }; + for (const [aggregator, nextReport] of contributions) { + reports[aggregator.id] = aggregator.merge( + reports[aggregator.id] ?? aggregator.emptyReport(), + nextReport, + ); + } + return reports; +} + +export type RuleKind = "analysis" | "transformation"; + +export type RuleRendererProps = { + readonly report: TReport; + readonly run: ReportRunMetadata; +}; + +export type RuleSliceDefinition = { + readonly id: string; + readonly route: string; + readonly title: string; + readonly domain: string; + readonly kind: RuleKind; + readonly Summary: (props: RuleRendererProps) => JSX.Element; + readonly Detail: (props: RuleRendererProps) => JSX.Element; +}; + +/** + * The host-facing descriptor erases the rule payload type. Only this rule-owned + * closure casts the matching-version payload and invokes its typed renderers. + */ +export type RuleSliceDescriptor = { + readonly id: string; + readonly route: string; + readonly title: string; + readonly domain: string; + readonly kind: RuleKind; + readonly renderSummary: (payload: JsonValue, run: ReportRunMetadata) => JSX.Element; + readonly renderDetail: (payload: JsonValue, run: ReportRunMetadata) => JSX.Element; +}; + +export function defineRuleSlice( + definition: RuleSliceDefinition, +): RuleSliceDescriptor { + return Object.freeze({ + id: definition.id, + route: definition.route, + title: definition.title, + domain: definition.domain, + kind: definition.kind, + renderSummary: (payload: JsonValue, run: ReportRunMetadata) => + definition.Summary({ report: payload as TReport, run }), + renderDetail: (payload: JsonValue, run: ReportRunMetadata) => + definition.Detail({ report: payload as TReport, run }), + }); +} + +export function createRuleManifest( + descriptors: readonly RuleSliceDescriptor[], +): readonly RuleSliceDescriptor[] { + const ids = new Set(); + const routes = new Set(); + + for (const descriptor of descriptors) { + if (!descriptor.id || ids.has(descriptor.id)) { + throw new Error(`Duplicate or empty rule id: ${descriptor.id || "(empty)"}`); + } + if (!isStableRoute(descriptor.route) || routes.has(descriptor.route)) { + throw new Error(`Duplicate or invalid rule route: ${descriptor.route || "(empty)"}`); + } + if (!descriptor.domain.trim() || !descriptor.title.trim()) { + throw new Error(`Rule ${descriptor.id} must declare a domain and title.`); + } + ids.add(descriptor.id); + routes.add(descriptor.route); + } + + return Object.freeze([...descriptors].sort(compareRuleMetadata)); +} + +function compareRuleMetadata( + left: RuleSliceDescriptor, + right: RuleSliceDescriptor, +): number { + return compareText(left.domain, right.domain) + || compareText(left.title, right.title) + || compareText(left.id, right.id); +} + +function compareText(left: string, right: string): number { + const normalizedLeft = left.toLocaleLowerCase(); + const normalizedRight = right.toLocaleLowerCase(); + if (normalizedLeft < normalizedRight) return -1; + if (normalizedLeft > normalizedRight) return 1; + return left < right ? -1 : left > right ? 1 : 0; +} + +function isStableRoute(route: string): boolean { + return /^[a-z0-9]+(?:[/-][a-z0-9]+)*$/.test(route); +} + +export function serializeReportEnvelope(envelope: ReportEnvelope): string { + assertReportEnvelope(envelope); + const serialized = JSON.stringify(envelope); + if (serialized === undefined) throw new Error("Report envelope is not serializable."); + return serialized + .replaceAll("&", "\\u0026") + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("
", "\\u2028") + .replaceAll("
", "\\u2029"); +} + +export type EmbeddedReportRoot = { + readonly getElementById: (id: string) => { + readonly tagName: string; + readonly textContent: string | null; + readonly getAttribute: (name: string) => string | null; + } | null; +}; + +export function readEmbeddedReport(root: EmbeddedReportRoot): ReportEnvelope { + const element = root.getElementById(EMBEDDED_REPORT_ID); + if (!element || element.tagName.toLowerCase() !== "script") { + throw new Error(`Missing embedded report script #${EMBEDDED_REPORT_ID}.`); + } + if (element.getAttribute("type") !== "application/json") { + throw new Error("Embedded report script must use type application/json."); + } + + let value: unknown; + try { + value = JSON.parse(element.textContent ?? ""); + } catch (error) { + throw new Error("Embedded report JSON is invalid.", { cause: error }); + } + assertReportEnvelope(value); + return value; +} + +export function assertReportEnvelope(value: unknown): asserts value is ReportEnvelope { + if (!isPlainObject(value) || value.schemaVersion !== REPORT_SCHEMA_VERSION) { + throw new Error(`Unsupported report schema; expected version ${REPORT_SCHEMA_VERSION}.`); + } + if (!isPlainObject(value.run) || typeof value.run.analyzedTargetRoot !== "string" || !isAbsoluteTargetRoot(value.run.analyzedTargetRoot)) { + throw new Error("Report envelope must contain an absolute analyzed target root."); + } + if (!isPlainObject(value.reports)) { + throw new Error("Report envelope must contain a reports object."); + } + for (const payload of Object.values(value.reports)) assertJsonValue(payload); +} + +function assertJsonValue(value: unknown, seen = new Set()): asserts value is JsonValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (typeof value !== "object") throw new Error("Report payload contains a non-JSON value."); + if (seen.has(value)) throw new Error("Report payload contains a circular reference."); + + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) assertJsonValue(item, seen); + } else { + if (!isPlainObject(value)) throw new Error("Report payload contains a non-plain object."); + for (const item of Object.values(value)) assertJsonValue(item, seen); + } + seen.delete(value); +} + +function isAbsoluteTargetRoot(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/codemods/solid-migration-assistant/shared/run-workflow.mjs b/codemods/solid-migration-assistant/shared/run-workflow.mjs index d85b34a..3a22b9a 100644 --- a/codemods/solid-migration-assistant/shared/run-workflow.mjs +++ b/codemods/solid-migration-assistant/shared/run-workflow.mjs @@ -1,8 +1,10 @@ import { spawnSync } from "node:child_process"; -import { readFileSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { renderReportHtml, writeReportAtomically } from "./report-artifact.mjs"; const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const require = createRequire(import.meta.url); @@ -21,36 +23,46 @@ Analyzer: ${packageMetadata.name}@${packageMetadata.version} Reviewed migration destination: ${SOLID_TARGET} (source commit ${SOLID_SOURCE_COMMIT}) Immutable migration guide: ${MIGRATION_GUIDE} Scope: This source-only analyzer covers project-owned .js, .jsx, .ts, and .tsx source. Coverage is incomplete and advisory, makes no migration-readiness claim, and supports only the exact destination above; other Solid versions are unsupported. -Execution: Read-only. The analyzer does not run target typechecks, builds, tests, scripts, or applications and creates no target report. -Privacy and output: Analyzer telemetry is disabled and no analyzer telemetry or generated report is emitted. Codemod may retain normal workflow or task state outside the target in platform user-data directories; consult Codemod's privacy and state behavior. +Execution: Read-only. The analyzer does not run target typechecks, builds, tests, scripts, or applications and creates no target report unless the user explicitly requests one with --report FILE. +Privacy and output: Analyzer telemetry is disabled; a generated report is emitted only when explicitly requested with --report FILE. Every generated HTML contains bounded project source snippets and the absolute analyzed target path; treat and share it as project source and local machine metadata. Codemod may retain normal workflow or task state outside the target in platform user-data directories; consult Codemod's privacy and state behavior. Feedback: ${FEEDBACK_URL} [solid-migration-assistant] End final disclosure`; export class CliUsageError extends Error {} -export function parseTarget(argumentsList) { - let target = "."; - let hasExplicitTarget = false; +export function parseArguments(argumentsList) { + const options = { target: ".", report: null, force: false, open: false }; + const seen = new Set(); for (let index = 0; index < argumentsList.length; index += 1) { const argument = argumentsList[index]; - if (argument !== "--target") { - throw new CliUsageError(`unknown argument: ${argument}`); - } - if (hasExplicitTarget) { - throw new CliUsageError("--target may only be specified once"); + if (argument === "--target" || argument === "--report") { + if (seen.has(argument)) throw new CliUsageError(`${argument} may only be specified once`); + const value = argumentsList[index + 1]; + if (!value || value.startsWith("--")) throw new CliUsageError(`${argument} requires a value`); + seen.add(argument); + if (argument === "--target") options.target = value; + else options.report = value; + index += 1; + continue; } - - const value = argumentsList[index + 1]; - if (!value || value.startsWith("--")) { - throw new CliUsageError("--target requires a value"); + if (argument === "--force" || argument === "--open") { + if (seen.has(argument)) throw new CliUsageError(`${argument} may only be specified once`); + seen.add(argument); + if (argument === "--force") options.force = true; + else options.open = true; + continue; } - target = value; - hasExplicitTarget = true; - index += 1; + throw new CliUsageError(`unknown argument: ${argument}`); } - return target; + if (options.force && options.report === null) throw new CliUsageError("--force requires --report FILE"); + if (options.open && options.report === null) throw new CliUsageError("--open requires --report FILE"); + return options; +} + +export function parseTarget(argumentsList) { + return parseArguments(argumentsList).target; } export function resolveCodemodLauncher() { @@ -71,28 +83,66 @@ export function buildCodemodArguments(target) { ]; } -export function runCodemod(target, { spawnImpl = spawnSync } = {}) { - return spawnImpl( +export function runCodemod( + target, + { reportDataFile, spawnImpl = spawnSync } = {}, +) { + const reportMode = Boolean(reportDataFile); + const result = spawnImpl( process.execPath, [resolveCodemodLauncher(), ...buildCodemodArguments(target)], { cwd: packageDirectory, - // Codemod routes all workflow step console output to its stderr and its - // progress envelope to stdout. Swap the child's fds so the guidance (the - // product) reaches our stdout while progress stays on our stderr. - stdio: [0, 2, 1], + // Codemod routes workflow output to stderr and progress to stdout. + // Default mode swaps those descriptors without buffering. Report mode + // captures both only long enough to remove the private data envelope, + // then forwards every existing terminal byte to the same destination. + stdio: reportMode ? [0, "pipe", "pipe"] : [0, 2, 1], + ...(reportMode + ? { + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, + env: { ...process.env, SOLID_MIGRATION_REPORT_MODE: "1" }, + } + : {}), }, ); + + if (reportMode) { + const { output, reportData } = extractReportData(result.stderr ?? ""); + if (output) process.stdout.write(output); + if (result.stdout) process.stderr.write(result.stdout); + if (reportData !== null) writeFileSync(reportDataFile, reportData, { encoding: "utf8", flag: "wx" }); + } + return result; +} + +export function extractReportData(output) { + const prefix = "__SOLID_MIGRATION_REPORT_DATA__"; + let reportData = null; + const terminalLines = []; + for (const line of output.split(/(?<=\n)/)) { + const marker = line.indexOf(prefix); + if (marker < 0) { + terminalLines.push(line); + continue; + } + if (reportData !== null) throw new Error("workflow emitted report data more than once"); + reportData = line.slice(marker + prefix.length).trimEnd(); + const before = line.slice(0, marker); + if (before.trim()) terminalLines.push(before.endsWith("\n") ? before : `${before}\n`); + } + return { output: terminalLines.join(""), reportData }; } export function main( argumentsList = process.argv.slice(2), - { cwd, runImpl = runCodemod } = {}, + { cwd, runImpl = runCodemod, openImpl = openReport } = {}, ) { let status = 1; try { - status = run(argumentsList, cwd ?? process.cwd(), runImpl); + status = run(argumentsList, cwd ?? process.cwd(), runImpl, openImpl); } catch (error) { console.error( `[solid-migration-assistant] analyzer execution failed: ${error instanceof Error ? error.message : String(error)}`, @@ -104,35 +154,72 @@ export function main( return status; } -function run(argumentsList, invocationDirectory, runImpl) { - let targetArgument; +function run(argumentsList, invocationDirectory, runImpl, openImpl) { + let options; try { - targetArgument = parseTarget(argumentsList); + options = parseArguments(argumentsList); } catch (error) { if (error instanceof CliUsageError) return fail(error.message); throw error; } - const target = resolve(invocationDirectory, targetArgument); + const target = resolve(invocationDirectory, options.target); try { - if (!statSync(target).isDirectory()) { - return fail(`target is not a directory: ${target}`); - } + if (!statSync(target).isDirectory()) return fail(`target is not a directory: ${target}`); } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return fail(`target does not exist: ${target}`); } throw error; } - const result = runImpl(target); + if (options.report === null) { + const result = runImpl(target); + if (result.error) throw result.error; + return result.status ?? 1; + } + + const reportPath = resolve(invocationDirectory, options.report); + if (existsSync(reportPath) && !options.force) { + return fail(`report already exists (use --force to replace it): ${reportPath}`); + } + if (existsSync(reportPath) && !statSync(reportPath).isFile()) { + return fail(`report path is not a file: ${reportPath}`); + } + + const dataDirectory = mkdtempSync(join(tmpdir(), "solid-migration-report-data-")); + const reportDataFile = join(dataDirectory, "report.json"); + try { + const result = runImpl(target, { reportDataFile }); + if (result.error) throw result.error; + const status = result.status ?? 1; + if (status !== 0) return status; + + const template = readFileSync(resolve(packageDirectory, "assets/dashboard/index.html"), "utf8"); + const reportData = readFileSync(reportDataFile, "utf8"); + const html = renderReportHtml(template, reportData, { analyzedTargetRoot: target }); + try { + writeReportAtomically(reportPath, html, { force: options.force }); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") { + return fail(`report already exists (use --force to replace it): ${reportPath}`); + } + throw error; + } + if (options.open) openImpl(reportPath); + return 0; + } finally { + rmSync(dataDirectory, { recursive: true, force: true }); + } +} + +export function openReport(path, { spawnImpl = spawnSync, platform = process.platform } = {}) { + const command = platform === "darwin" ? ["open", [path]] + : platform === "win32" ? ["cmd", ["/c", "start", "", path]] + : ["xdg-open", [path]]; + const result = spawnImpl(command[0], command[1], { stdio: "ignore" }); if (result.error) throw result.error; - return result.status ?? 1; + if (result.status !== 0) throw new Error(`browser launcher exited with status ${String(result.status)}`); } function fail(message) { diff --git a/codemods/solid-migration-assistant/shared/transform.ts b/codemods/solid-migration-assistant/shared/transform.ts new file mode 100644 index 0000000..a2a6f4f --- /dev/null +++ b/codemods/solid-migration-assistant/shared/transform.ts @@ -0,0 +1,7 @@ +/** + * Workflow-level report state key for the transform workflow. Transform + * rules live under rules/transformations/ and report through this shared + * key; this module intentionally holds no rule logic. + */ +export const TRANSFORM_REPORT_STATE_KEY = + "solid-migration-assistant-transform-report"; diff --git a/codemods/solid-migration-assistant/tests/architecture.test.mjs b/codemods/solid-migration-assistant/tests/architecture.test.mjs index 6423e79..a146dc6 100644 --- a/codemods/solid-migration-assistant/tests/architecture.test.mjs +++ b/codemods/solid-migration-assistant/tests/architecture.test.mjs @@ -5,11 +5,12 @@ import { test } from "node:test"; import { fileURLToPath } from "node:url"; const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const rulesDirectory = resolve(packageDirectory, "rules"); +const analysisDirectory = resolve(packageDirectory, "rules/analysis"); +const transformationsDirectory = resolve(packageDirectory, "rules/transformations"); const testsDirectory = resolve(packageDirectory, "tests"); -const EXPECTED_RULE_PRODUCTION = [ - "imports/beta32-subpaths/beta32-subpaths.ts", +const EXPECTED_ANALYSIS_PRODUCTION = [ +"imports/beta32-subpaths/beta32-subpaths.ts", "imports/web-import/web-import.ts", "jsx/class-list/class-list.ts", "jsx/component-renames/component-renames.ts", @@ -38,8 +39,8 @@ const EXPECTED_RULE_PRODUCTION = [ "store/produce/produce.ts", "store/unwrap/unwrap.ts", ]; -const EXPECTED_RULE_TESTS = [ - "imports/beta32-subpaths/beta32-subpaths.test.ts", +const EXPECTED_ANALYSIS_TESTS = [ +"imports/beta32-subpaths/beta32-subpaths.test.ts", "imports/web-import/web-import.test.ts", "jsx/class-list/class-list.test.ts", "jsx/component-renames/component-renames.test.ts", @@ -68,8 +69,8 @@ const EXPECTED_RULE_TESTS = [ "store/produce/produce.test.ts", "store/unwrap/unwrap.test.ts", ]; -const EXPECTED_RULE_FIXTURES = [ - "imports/beta32-subpaths/static-imports.fixture.tsx", +const EXPECTED_ANALYSIS_FIXTURES = [ +"imports/beta32-subpaths/static-imports.fixture.tsx", "imports/web-import/static-imports.fixture.tsx", "jsx/class-list/normal.fixture.tsx", "jsx/component-renames/normal.fixture.tsx", @@ -119,34 +120,62 @@ const EXPECTED_RULE_FIXTURES = [ "store/unwrap/non-solid.fixture.tsx", ]; -const EXPECTED_RULE_FOLDERS = EXPECTED_RULE_PRODUCTION.map((path) => +const EXPECTED_ANALYSIS_FOLDERS = EXPECTED_ANALYSIS_PRODUCTION.map((path) => dirname(path), ).sort(); -test("keeps the production workflow detection-only", () => { - assert.deepEqual(productionScripts(), ["analyze.ts", "emit.ts"]); +const EXPECTED_TRANSFORM_PRODUCTION = [ + "imports/legacy-subpath-relocation/legacy-subpath-relocation.ts", +]; +const EXPECTED_TRANSFORM_TESTS = [ + "imports/legacy-subpath-relocation/legacy-subpath-relocation.test.ts", +]; +const EXPECTED_TRANSFORM_FIXTURES = [ + "imports/legacy-subpath-relocation/fixtures/destructured-require.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/escaped-specifiers.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/negative-forms.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/no-matches.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/prototype-names.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/re-exports.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/relocations.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/runtime-forms.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/shadowed-require.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/single-quotes.fixture.tsx", + "imports/legacy-subpath-relocation/fixtures/static-imports.fixture.tsx", +]; +const EXPECTED_TRANSFORM_FOLDERS = EXPECTED_TRANSFORM_PRODUCTION.map((path) => + dirname(path), +).sort(); + +test("ships read-only analyze and deterministic transform workflows", () => { + assert.deepEqual( + productionScripts(), + ["analyze.ts", "emit-report.ts", "transform.ts"], + ); assert.deepEqual(workflowFiles(), ["workflow.yaml"]); + assert.equal( + existsSync(resolve(packageDirectory, "transform.yaml")), + true, + ); for (const path of [ - "scripts/transform.ts", "scripts/write-report.ts", - "shared/report.ts", "shared/report-path.ts", "workflow.transform.yaml", ]) { assert.equal(existsSync(resolve(packageDirectory, path)), false, path); } - const workflow = readFileSync( + const analyzeWorkflow = readFileSync( resolve(packageDirectory, "workflow.yaml"), "utf8", ); assert.deepEqual( - [...workflow.matchAll(/js_file:\s*(\S+)/g)].map((match) => match[1]), - ["scripts/analyze.ts", "scripts/emit.ts"], + [...analyzeWorkflow.matchAll(/js_file:\s*(\S+)/g)].map((match) => match[1]), + ["scripts/analyze.ts", "scripts/emit-report.ts"], ); assert.deepEqual( - [...workflow.matchAll(/- "(\*\*\/\*\.(?:js|jsx|ts|tsx))"/g)].map( + [...analyzeWorkflow.matchAll(/- "(\*\*\/\*\.(?:js|jsx|ts|tsx))"/g)].map( (match) => match[1], ), [ @@ -160,28 +189,64 @@ test("keeps the production workflow detection-only", () => { "**/*.tsx", ], ); - assert.equal((workflow.match(/semantic_analysis: workspace/g) ?? []).length, 1); - assert.doesNotMatch(workflow, /semantic_analysis: file/); - for (const exclusion of [ - "node_modules", - "dist", - "build", - "coverage", - ]) { + assert.equal( + (analyzeWorkflow.match(/semantic_analysis: workspace/g) ?? []).length, + 1, + ); + assert.doesNotMatch(analyzeWorkflow, /semantic_analysis: file/); + for (const exclusion of ["node_modules", "dist", "build", "coverage"]) { assert.equal( - (workflow.match(new RegExp(`- "\\*\\*/${exclusion}/\\*\\*"`, "g")) ?? []) + (analyzeWorkflow.match(new RegExp(`- "\\*\\*/${exclusion}/\\*\\*"`, "g")) ?? []) .length, 2, exclusion, ); } - assert.equal((workflow.match(/- "\*\*\/\*\.d\.ts"/g) ?? []).length, 2); - assert.doesNotMatch(workflow, /transform|write.report|\.codemod-reports/i); + assert.equal((analyzeWorkflow.match(/- "\*\*\/\*\.d\.ts"/g) ?? []).length, 2); + assert.doesNotMatch(analyzeWorkflow, /transform|write.report|\.codemod-reports/i); + + const transformWorkflow = readFileSync( + resolve(packageDirectory, "transform.yaml"), + "utf8", + ); + assert.deepEqual( + [...transformWorkflow.matchAll(/js_file:\s*(\S+)/g)].map((match) => match[1]), + ["scripts/transform.ts", "scripts/emit-report.ts"], + ); + assert.deepEqual( + [...transformWorkflow.matchAll(/- "(\*\*\/\*\.(?:js|jsx|ts|tsx))"/g)].map( + (match) => match[1], + ), + [ + "**/*.js", + "**/*.jsx", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.ts", + "**/*.tsx", + ], + ); + for (const exclusion of ["node_modules", "dist", "build", "coverage"]) { + assert.equal( + (transformWorkflow.match(new RegExp(`- "\\*\\*/${exclusion}/\\*\\*"`, "g")) ?? []) + .length, + 2, + exclusion, + ); + } + assert.equal((transformWorkflow.match(/- "\*\*\/\*\.d\.ts"/g) ?? []).length, 2); + assert.equal((transformWorkflow.match(/max_threads: 1/g) ?? []).length, 1); + assert.doesNotMatch( + transformWorkflow, + /semantic_analysis|scripts\/analyze\.ts|\.codemod-reports/i, + ); }); test("registers every supported detector and one deterministic emitter", () => { - for (const path of EXPECTED_RULE_PRODUCTION) { - assert.equal(existsSync(resolve(rulesDirectory, path)), true, path); + for (const path of EXPECTED_ANALYSIS_PRODUCTION) { + assert.equal(existsSync(resolve(analysisDirectory, path)), true, path); } const analyzer = readFileSync( @@ -194,6 +259,7 @@ test("registers every supported detector and one deterministic emitter", () => { "analyzeJsxClassListAttributes", "analyzeJsxComponentRenames", "analyzeOnMount", + "analyzeOnCleanup", "analyzeMergeProps", "analyzeSplitProps", "analyzeCreateComputed", @@ -230,7 +296,7 @@ test("registers every supported detector and one deterministic emitter", () => { } const emitter = readFileSync( - resolve(packageDirectory, "scripts/emit.ts"), + resolve(packageDirectory, "scripts/emit-report.ts"), "utf8", ); assert.match(emitter, /\.sort\(\)/); @@ -240,10 +306,11 @@ test("registers every supported detector and one deterministic emitter", () => { "utf8", ); assert.doesNotMatch(analysis, /compareGuidance|guidanceLocation|siteGuidance/); - assert.equal((emitter.match(/console\.log\(/g) ?? []).length, 1); + assert.equal((emitter.match(/console\.log\(/g) ?? []).length, 2); + assert.match(emitter, /__SOLID_MIGRATION_REPORT_DATA__/); }); -test("exposes analysis without report or transform commands", () => { +test("exposes analyze and transform workflows", () => { const packageJson = JSON.parse( readFileSync(resolve(packageDirectory, "package.json"), "utf8"), ); @@ -253,70 +320,103 @@ test("exposes analysis without report or transform commands", () => { ); assert.equal(typeof packageJson.scripts.analyze, "string"); + assert.equal(typeof packageJson.scripts.transform, "string"); assert.deepEqual( - Object.keys(packageJson.scripts).filter((name) => - /transform|report/i.test(name), - ), - [], + Object.keys(packageJson.scripts) + .filter((name) => /^test:transform/.test(name)) + .sort(), + ["test:transform", "test:transform-rules"], ); assert.match(codemod, /- name: analyze/); - assert.doesNotMatch(codemod, /name: transform|workflow\.transform|report/i); + assert.match(codemod, /- name: transform/); + assert.doesNotMatch(codemod, /name: write|report\.yaml/i); +}); + +test("colocates exact analysis rule production, adapters, and fixtures", () => { + assert.deepEqual(directRuleFolders(analysisDirectory), EXPECTED_ANALYSIS_FOLDERS); + assert.deepEqual( + ruleFiles(analysisDirectory, (name) => name.endsWith(".ts") && !name.endsWith(".test.ts") && name !== "report.ts"), + EXPECTED_ANALYSIS_PRODUCTION, + ); + assert.deepEqual( + ruleFiles(analysisDirectory, (name) => name.endsWith(".test.ts")), + EXPECTED_ANALYSIS_TESTS, + ); + assert.deepEqual( + ruleFiles(analysisDirectory, (name) => name.endsWith(".fixture.tsx")), + EXPECTED_ANALYSIS_FIXTURES, + ); + assertRuleLayout(analysisDirectory, EXPECTED_ANALYSIS_PRODUCTION); }); -test("colocates exact rule production, adapters, and fixtures", () => { - assert.deepEqual(directRuleFolders(), EXPECTED_RULE_FOLDERS); +test("colocates exact transformation rule production, adapters, and fixtures", () => { assert.deepEqual( - ruleFiles((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")), - EXPECTED_RULE_PRODUCTION, + directRuleFolders(transformationsDirectory), + EXPECTED_TRANSFORM_FOLDERS, ); assert.deepEqual( - ruleFiles((name) => name.endsWith(".test.ts")), - EXPECTED_RULE_TESTS, + ruleFiles(transformationsDirectory, (name) => name.endsWith(".ts") && !name.endsWith(".test.ts") && name !== "report.ts"), + EXPECTED_TRANSFORM_PRODUCTION, ); assert.deepEqual( - ruleFiles((name) => name.endsWith(".fixture.tsx")), - EXPECTED_RULE_FIXTURES, + ruleFiles(transformationsDirectory, (name) => name.endsWith(".test.ts")), + EXPECTED_TRANSFORM_TESTS, ); + assert.deepEqual( + ruleFiles(transformationsDirectory, (name) => name.endsWith(".fixture.tsx")), + EXPECTED_TRANSFORM_FIXTURES, + ); + assertRuleLayout(transformationsDirectory, EXPECTED_TRANSFORM_PRODUCTION, { + fixturesSubdirectory: true, + }); +}); - for (const path of ruleEntries()) { - assert.equal(path.split("/").includes("__testfixtures__"), false, path); - assert.notEqual(basename(path), "input.tsx", path); - assert.notEqual(basename(path), "expected.tsx", path); +test("colocates contracts and Solid renderers only for selected pilot slices", () => { + const pilots = [ + [analysisDirectory, "imports/web-import"], + [analysisDirectory, "jsx/component-renames"], + [analysisDirectory, "reactivity/create-effect"], + [transformationsDirectory, "imports/legacy-subpath-relocation"], + ]; + for (const [directory, folder] of pilots) { + assert.equal(existsSync(resolve(directory, folder, "report.ts")), true, folder); + assert.equal(existsSync(resolve(directory, folder, "ui.tsx")), true, folder); + const reportSource = readFileSync(resolve(directory, folder, "report.ts"), "utf8"); + const uiSource = readFileSync(resolve(directory, folder, "ui.tsx"), "utf8"); + assert.match(reportSource, /summary: string/); + assert.match(reportSource, /reason: string/); + assert.match(reportSource, /nextSteps: readonly string\[\]/); + assert.match(reportSource, /officialGuideUrl: string/); + assert.doesNotMatch(reportSource, /guidance: string/); + assert.match(uiSource, /GuidanceSections/); + assert.match(uiSource, /summary=\{finding\.summary\}/); + assert.doesNotMatch(uiSource, /finding\.guidance|\.split\(/); } + assert.equal(ruleFiles(analysisDirectory, (name) => name === "ui.tsx").length, 3); + assert.equal(ruleFiles(transformationsDirectory, (name) => name === "ui.tsx").length, 1); +}); - for (const production of EXPECTED_RULE_PRODUCTION) { - const folder = dirname(production); - const ruleName = basename(production, ".ts"); - const entries = readdirSync(resolve(rulesDirectory, folder), { - withFileTypes: true, - }); - const files = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .sort(); - const directories = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); +test("keeps direct editor and copy utility actions keyboard-native", () => { + const app = readFileSync(resolve(packageDirectory, "dashboard/app.tsx"), "utf8"); + const findings = readFileSync(resolve(packageDirectory, "dashboard/finding-ui.tsx"), "utf8"); + const copyButton = readFileSync(resolve(packageDirectory, "dashboard/copy-button.tsx"), "utf8"); + const styles = readFileSync(resolve(packageDirectory, "dashboard/styles.css"), "utf8"); + assert.match(findings, /class="open-editor"/); + assert.match(findings, /class="overflow-actions"/); + assert.match(findings, /idleLabel="Copy location"/); + assert.match(app, /idleLabel="Copy root"/); + assert.match(copyButton, /