From 336ffb12f8bd1ab3008913c509f31081e3fa1b38 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:29:50 +0100 Subject: [PATCH 01/11] feat(migrate): convert components through convert_component Migrate now accepts what the analysis rates Astro-convertible: arrow functions, a declaration named by export default, expression statements, and any object pattern of a component's props. Pages that read params stay preserved. Co-Authored-By: Claude Opus 5.5 --- .../frameshift-engine/src/migrate/convert.rs | 245 ++++++++++-------- docs/IMPLEMENTATION.md | 6 +- docs/ROADMAP.md | 2 +- .../migrate/static-site/expected/dry-run.txt | 7 +- .../expected/output/.frameshift/SUMMARY.md | 3 +- .../expected/output/src/components/Card.astro | 7 + .../expected/output/src/pages/arrow.astro | 10 + .../output/src/pages/statements.astro | 14 + .../migrate/static-site/expected/plan.json | 41 ++- .../migrate/static-site/expected/plan.txt | 4 + fixtures/migrate/static-site/fixture.json | 2 +- .../static-site/source/app/arrow/page.tsx | 12 + .../source/app/statements/page.tsx | 14 + .../static-site/source/components/Card.tsx | 4 + 14 files changed, 252 insertions(+), 119 deletions(-) create mode 100644 fixtures/migrate/static-site/expected/output/src/components/Card.astro create mode 100644 fixtures/migrate/static-site/expected/output/src/pages/arrow.astro create mode 100644 fixtures/migrate/static-site/expected/output/src/pages/statements.astro create mode 100644 fixtures/migrate/static-site/source/app/arrow/page.tsx create mode 100644 fixtures/migrate/static-site/source/app/statements/page.tsx create mode 100644 fixtures/migrate/static-site/source/components/Card.tsx diff --git a/crates/frameshift-engine/src/migrate/convert.rs b/crates/frameshift-engine/src/migrate/convert.rs index 5420406..717afcc 100644 --- a/crates/frameshift-engine/src/migrate/convert.rs +++ b/crates/frameshift-engine/src/migrate/convert.rs @@ -9,16 +9,16 @@ use std::collections::{BTreeMap, BTreeSet}; use frameshift_core::{SourcePath, SourceText, Span}; use frameshift_emit::{ - Anchor, AstroComponent, AttributeItem, AttributeName, AttributeValue, ComponentName, - ComponentReference, ComponentStrategy, Element, ElementResolver, EmittedText, Expression, - ExpressionPiece, FrontmatterItem, HtmlTagName, ImportItem, JsIdentifier, JsxConversion, + Anchor, AstroComponent, AttributeItem, AttributeName, AttributeValue, ComponentConversion, + ComponentName, ComponentReference, ComponentStrategy, Element, ElementResolver, EmittedText, + Expression, ExpressionPiece, FrontmatterItem, HtmlTagName, ImportItem, JsIdentifier, NamedImport, PrintOptions, PropMember, PropType, RegionOrigin, SourceId, TagName, TemplateNode, - convert_jsx, print_astro_component, + convert_component, print_astro_component, }; use frameshift_modules::{ModuleGraph, Resolution}; use frameshift_syntax::{ - ChildList, FunctionKind, JsDialect, MetaPropertyKind, NodeId, SourceGoal, SyntaxNodeKind, - SyntaxTree, TemplatePart, TypeScriptKind, VariableKind, parse_js_program, + ChildList, JsDialect, MetaPropertyKind, NodeId, SourceGoal, SyntaxNodeKind, SyntaxTree, + TemplatePart, TypeScriptKind, VariableKind, parse_js_program, }; use frameshift_transform::{Edit, EditOrder, apply_edits}; @@ -280,12 +280,70 @@ struct Analysis { module_items: Vec, // Module declarations that the frontmatter copies. module_copies: Vec, - // Function body statements that the frontmatter copies. - leading: Vec, metadata: Option, - function: Span, - props: Vec, - returned: NodeId, + function: NodeId, +} + +// Whether a node is a function that can be a component. +fn is_function(tree: &SyntaxTree<'_>, id: NodeId) -> Result { + Ok(matches!( + kind(tree, id)?, + SyntaxNodeKind::FunctionDeclaration { .. } + | SyntaxNodeKind::FunctionExpression { .. } + | SyntaxNodeKind::ArrowFunction { .. } + )) +} + +// The name in `export default `, whose declaration is the component. +fn default_export_name(tree: &SyntaxTree<'_>) -> Result> { + for item in program_body(tree)? { + if let SyntaxNodeKind::ExportDefault { declaration } = kind(tree, item)? + && matches!(kind(tree, declaration)?, SyntaxNodeKind::Identifier { .. }) + { + return Ok(Some(node_text(tree, declaration)?.to_owned())); + } + } + Ok(None) +} + +// The function that a module-level `const` or function declaration holds. +fn declared_function(tree: &SyntaxTree<'_>, item: NodeId) -> Result> { + if let SyntaxNodeKind::FunctionDeclaration { + name: Some(name), .. + } = kind(tree, item)? + { + return Ok(Some((node_text(tree, name)?.to_owned(), item))); + } + if let Some((name, init)) = const_export(tree, item)? + && is_function(tree, init)? + { + return Ok(Some((name, init))); + } + Ok(None) +} + +// The parameter list and the statements before the final `return`. +fn function_parts(tree: &SyntaxTree<'_>, function: NodeId) -> Result<(NodeId, Vec)> { + let (parameters, body) = if let SyntaxNodeKind::FunctionDeclaration { + parameters, body, .. + } + | SyntaxNodeKind::FunctionExpression { + parameters, body, .. + } + | SyntaxNodeKind::ArrowFunction { + parameters, body, .. + } = kind(tree, function)? + { + (parameters, body) + } else { + return refuse("the component is not a function"); + }; + let mut leading = Vec::new(); + if let SyntaxNodeKind::FunctionBody { body, .. } = kind(tree, body)? { + leading = list(tree, body)?; + leading.pop(); + } + Ok((parameters, leading)) } struct Resolver { @@ -318,11 +376,10 @@ pub(crate) fn component_name(path: &str, text: &SourceText, limits: &Limits) -> else { continue; }; - if let SyntaxNodeKind::FunctionDeclaration { - name: Some(name), .. - } = kind(&tree, declaration)? - { - names.push(node_text(&tree, name)?.to_owned()); + if let Some((name, _)) = declared_function(&tree, declaration)? { + names.push(name); + } else if matches!(kind(&tree, declaration)?, SyntaxNodeKind::Identifier { .. }) { + names.push(node_text(&tree, declaration)?.to_owned()); } else { return refuse("a component module exports something other than a function"); } @@ -380,9 +437,10 @@ pub(crate) fn astro( if binds_any(&tree, tree.root(), &generated)? { return refuse("the file already binds a name that the conversion generates"); } - check_frontmatter_scope(&tree, &analysis)?; + let (parameters, leading) = function_parts(&tree, analysis.function)?; + check_frontmatter_scope(&tree, &analysis, parameters, &leading)?; let anchor = |span: Span| Anchor { source, span }; - let function_anchor = anchor(analysis.function); + let function_anchor = anchor(node_span(&tree, analysis.function)?); let mut frontmatter = Vec::new(); let mut resolver = Resolver { link: None, @@ -408,22 +466,26 @@ pub(crate) fn astro( } } } - let children = analysis.props.iter().any(|prop| prop == "children"); - let mut props: Vec = analysis - .props - .iter() - .filter(|prop| prop.as_str() != "children") - .cloned() - .collect(); + // A component's whole parameter pattern reads `Astro.props`. Pages and + // layouts receive route values instead, so their pattern is checked here + // and never copied. + let mut props: Vec = Vec::new(); + if !matches!(role, Role::Component) { + let names = self::props(&tree, parameters)?; + if matches!(role, Role::Page { .. }) && !names.is_empty() { + return refuse("pages that read props are not converted by migrate v0"); + } + props = names + .into_iter() + .filter(|name| name != "children") + .collect(); + } match role { Role::Page { layout_name, layout_output, .. } => { - if !analysis.props.is_empty() { - return refuse("pages that read props are not converted by migrate v0"); - } frontmatter.push(FrontmatterItem::Import(ImportItem { default: Some(identifier(layout_name)?), named: Vec::new(), @@ -480,9 +542,6 @@ pub(crate) fn astro( } } } - if children && matches!(role, Role::Page { .. }) { - return refuse("pages do not render children"); - } if !props.is_empty() { frontmatter.push(FrontmatterItem::PropsBinding { names: props @@ -498,15 +557,20 @@ pub(crate) fn astro( .iter() .map(|span| FrontmatterItem::Copied(anchor(*span))), ); - check_links(&tree, resolver.link.as_deref(), analysis.returned)?; - let conversion = JsxConversion { - children_binding: children.then_some("children"), - ..JsxConversion::default() - }; - let markup = convert_jsx(&tree, source, analysis.returned, &conversion, &mut resolver) - .map_err(|error| format!("the JSX is outside the converted subset ({error})"))?; + check_links(&tree, resolver.link.as_deref(), analysis.function)?; + let converted = convert_component( + &tree, + source, + analysis.function, + &ComponentConversion::default(), + &mut resolver, + ) + .map_err(|error| format!("the component is outside the converted subset ({error})"))?; + frontmatter.extend(converted.component.frontmatter.into_iter().filter(|item| { + matches!(role, Role::Component) || !matches!(item, FrontmatterItem::PropsPattern(_)) + })); let mut assumptions: Vec<&'static str> = - markup.assumptions.iter().map(|a| a.kind.id()).collect(); + converted.assumptions.iter().map(|a| a.kind.id()).collect(); // Frontmatter runs on every render, a module once. A v0 page renders once // per route, but a layout or component may render many times. if !matches!(role, Role::Page { .. }) { @@ -518,7 +582,7 @@ pub(crate) fn astro( } assumptions.sort_unstable(); assumptions.dedup(); - let mut template = markup.nodes; + let mut template = converted.component.template; let root_source; let sources: Vec<&SourceText> = match role { Role::RootLayout => { @@ -586,22 +650,24 @@ fn binds_any(tree: &SyntaxTree<'_>, id: NodeId, names: &[&str]) -> Result // A function's parameter and body statements leave their function scope for // the module's frontmatter scope, where they must not meet another binding of // the same name, and where no copied module declaration may start to read them. -fn check_frontmatter_scope(tree: &SyntaxTree<'_>, analysis: &Analysis) -> Result<()> { +fn check_frontmatter_scope( + tree: &SyntaxTree<'_>, + analysis: &Analysis, + parameters: NodeId, + leading: &[NodeId], +) -> Result<()> { let mut module = BTreeSet::new(); for &item in &analysis.module_items { bound_names(tree, item, &mut module)?; } let mut body = BTreeSet::new(); - for &statement in &analysis.leading { + for &statement in leading { bound_names(tree, statement, &mut body)?; } - // `children` becomes the slot and binds nothing in the frontmatter. - let props: BTreeSet = analysis - .props - .iter() - .filter(|prop| prop.as_str() != "children") - .cloned() - .collect(); + let mut props = BTreeSet::new(); + bound_names(tree, parameters, &mut props)?; + // `children` becomes the slot, which nothing else can read. + props.remove("children"); if !module.is_disjoint(&body) || !module.is_disjoint(&props) || !body.is_disjoint(&props) { return refuse("a function binding would share the frontmatter scope with another binding"); } @@ -767,6 +833,7 @@ fn analyze( role: &Role<'_>, ) -> Result { let is_component = matches!(role, Role::Component); + let default_name = default_export_name(tree)?; let mut imports = Vec::new(); let mut copies = Vec::new(); let mut module_items = Vec::new(); @@ -802,13 +869,11 @@ fn analyze( } imports.push(Import::Dropped); } else if let SyntaxNodeKind::ExportDeclaration { declaration } = item_kind { - if let SyntaxNodeKind::FunctionDeclaration { .. } = kind(tree, declaration)? - && is_component - { + if is_component && let Some((_, found)) = declared_function(tree, declaration)? { if function.is_some() { return refuse("a component module exports several functions"); } - function = Some(declaration); + function = Some(found); } else if is_dropped_segment_option(tree, declaration)? { } else if let Some(found) = metadata_export(tree, declaration, matches!(role, Role::RootLayout))? @@ -821,16 +886,31 @@ fn analyze( return refuse("an export other than metadata or the component"); } } else if let SyntaxNodeKind::ExportDefault { declaration } = item_kind { - if function.is_some() { - return refuse("a module exports several functions"); + // `export default ` names a declaration of this module. + if !matches!(kind(tree, declaration)?, SyntaxNodeKind::Identifier { .. }) { + if !is_function(tree, declaration)? { + return refuse("the default export is not a function"); + } + if function.is_some() { + return refuse("a module exports several functions"); + } + function = Some(declaration); } - function = Some(declaration); } else if let SyntaxNodeKind::VariableDeclaration { kind: VariableKind::Const, .. } | SyntaxNodeKind::FunctionDeclaration { .. } = item_kind { + if let Some((name, found)) = declared_function(tree, item)? + && default_name.as_deref() == Some(name.as_str()) + { + if function.is_some() { + return refuse("a module exports several functions"); + } + function = Some(found); + continue; + } if contains_jsx(tree, item)? { return refuse("module-level JSX is not converted by migrate v0"); } @@ -847,64 +927,13 @@ fn analyze( let Some(function) = function else { return refuse("no exported function component"); }; - let SyntaxNodeKind::FunctionDeclaration { - name: _, - parameters, - body, - kind: function_kind, - } = kind(tree, function)? - else { - return refuse("the component must be a function declaration"); - }; - if !matches!(function_kind, FunctionKind::Normal | FunctionKind::Async) { - return refuse("generator components are not converted"); - } - let props = props(tree, parameters)?; - let SyntaxNodeKind::FunctionBody { - body: statements, .. - } = kind(tree, body)? - else { - return refuse("the component has no function body"); - }; - let statements = list(tree, statements)?; - let Some((&last, leading)) = statements.split_last() else { - return refuse("the component body is empty"); - }; - let mut previous_end = node_span(tree, body)?.start() + 1; - for &statement in leading { - let statement_span = node_span(tree, statement)?; - if let Some(comments) = comments_between(tree, previous_end, statement_span.start()) { - copies.push(comments); - } - previous_end = statement_span.end(); - let SyntaxNodeKind::VariableDeclaration { .. } = kind(tree, statement)? else { - return refuse("only declarations may precede the final return"); - }; - if contains_jsx(tree, statement)? { - return refuse("JSX outside the returned markup is not converted"); - } - copies.push(statement_span); - } - let last_span = node_span(tree, last)?; - if let Some(comments) = comments_between(tree, previous_end, last_span.start()) { - copies.push(comments); - } - let SyntaxNodeKind::ReturnStatement { - argument: Some(returned), - } = kind(tree, last)? - else { - return refuse("the body must end with a return of JSX"); - }; Ok(Analysis { imports, copies, module_items, module_copies, - leading: leading.to_vec(), metadata, - function: node_span(tree, function)?, - props, - returned, + function, }) } diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 83b31d5..ef27bce 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1941,7 +1941,7 @@ The component total comprises 4,220 client-context and 397 server-only definitio Output paths are validated as `OutputPath` values and compared without ASCII case; generated files are reserved first, and a later colliding file is preserved. Rendering then runs to a fixed point: a file whose rule refuses it is preserved with the reason, and the round repeats, because pages need their layout chain and every file needs its local imports to be written. Every preserved file is copied below `.frameshift/unmigrated/` at its package-relative path and becomes a `Preserved` task. Converted files that the analysis rates `review`, or whose JSX conversion relies on value-dependent assumptions, become `Review` tasks. -**Rules** (`migrate/convert.rs`, promoted from the E2 driver of section 11). A converted page, layout, or component may contain imports, type-only imports (dropped), module-level `const` declarations and function declarations without JSX (copied into the frontmatter), `export const metadata` with static `title` and `description` (a root layout may use a `default`/`template` title), `export const dynamic` with `auto`, `force-static`, or `error` and `export const runtime` (dropped), and one exported function declaration whose body is declarations followed by one `return` of JSX. Pages take no props; layouts take only `children`; components take shorthand props without defaults, and `children` becomes ``. Every import resolves through its exact module-graph edge: a local target must be written by the plan (a converted component is imported as the default export of its `.astro` file, other targets keep their bindings with a relative specifier), `next/link` becomes `` under E2's link checks, `server-only` is dropped, and other `next`, `react`, and `react-dom` imports refuse the file. Any other runtime edge of a converted file to a local or unresolved target (a dynamic `import()`, a re-export, or a `require` in copied code) refuses it, because only static import declarations are rewritten. The root layout must render one `html` element without a custom `head`; the generated head holds the charset and viewport tags and the metadata props. A nested layout wraps its parent layout and forwards the metadata props. A page renders its nearest layout with the title and description that Next.js would resolve against the root layout. A file that already binds a name the conversion declares (the layout component of a page, the parent layout and forwarded props of a nested layout, or a root layout's metadata props) is refused. A function's props and body bindings move into the frontmatter scope behind the copied module declarations, so a clash with an import, a module declaration, or each other, a copied module declaration that spells one of those names, and any binding named `Astro` refuse the file. Every comment must reach the output, and emitted provenance is verified. Kept scripts, including declaration files, receive span edits that rewrite each local specifier, including type-only imports and import types, to a relative output specifier (extensionless for JavaScript and TypeScript targets) and remove `import 'server-only'`; imports of `next` or `react-dom`, unresolved edges, imports of converted Astro files, `import.meta`, ambient modules with a non-wildcard name, and triple-slash path references refuse them, because the module graph has no edge for the last three. A stylesheet is relocated only when the CSS tokenizer of section 33 reports no error and, on its tokens, every URL token and every string in `url()`, `src()`, `image()`, or `image-set()` is root-absolute, a data URL, a remote URL, or a fragment other than `#{`, and every `@import`, `@use`, or `@forward` target is a remote or data URL. Comments are separate tokens, so they cannot hide a relative reference. Path aliases are not retargeted; all rewritten specifiers are relative. +**Rules** (`migrate/convert.rs`, promoted from the E2 driver of section 11). A converted page, layout, or component may contain imports, type-only imports (dropped), module-level `const` declarations and function declarations without JSX (copied into the frontmatter), `export const metadata` with static `title` and `description` (a root layout may use a `default`/`template` title), `export const dynamic` with `auto`, `force-static`, or `error` and `export const runtime` (dropped), and one component function: an exported function declaration, an exported `const` that holds a function (for components), or a function or `const` that `export default ` names. The function goes through `convert_component` (section 10), so its body is whatever that conversion accepts. Pages take no props; layouts take only `children`; a component's whole object pattern becomes `const = Astro.props;`, and `children` becomes ``. Every import resolves through its exact module-graph edge: a local target must be written by the plan (a converted component is imported as the default export of its `.astro` file, other targets keep their bindings with a relative specifier), `next/link` becomes `` under E2's link checks, `server-only` is dropped, and other `next`, `react`, and `react-dom` imports refuse the file. Any other runtime edge of a converted file to a local or unresolved target (a dynamic `import()`, a re-export, or a `require` in copied code) refuses it, because only static import declarations are rewritten. The root layout must render one `html` element without a custom `head`; the generated head holds the charset and viewport tags and the metadata props. A nested layout wraps its parent layout and forwards the metadata props. A page renders its nearest layout with the title and description that Next.js would resolve against the root layout. A file that already binds a name the conversion declares (the layout component of a page, the parent layout and forwarded props of a nested layout, or a root layout's metadata props) is refused. A function's props and body bindings move into the frontmatter scope behind the copied module declarations, so a clash with an import, a module declaration, or each other, a copied module declaration that spells one of those names, and any binding named `Astro` refuse the file. Every comment must reach the output, and emitted provenance is verified. Kept scripts, including declaration files, receive span edits that rewrite each local specifier, including type-only imports and import types, to a relative output specifier (extensionless for JavaScript and TypeScript targets) and remove `import 'server-only'`; imports of `next` or `react-dom`, unresolved edges, imports of converted Astro files, `import.meta`, ambient modules with a non-wildcard name, and triple-slash path references refuse them, because the module graph has no edge for the last three. A stylesheet is relocated only when the CSS tokenizer of section 33 reports no error and, on its tokens, every URL token and every string in `url()`, `src()`, `image()`, or `image-set()` is root-absolute, a data URL, a remote URL, or a fragment other than `#{`, and every `@import`, `@use`, or `@forward` target is a remote or data URL. Comments are separate tokens, so they cannot hide a relative reference. Path aliases are not retargeted; all rewritten specifiers are relative. **Scaffold** (`migrate/scaffold.rs`). `package.json` keeps the source name and the other `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`, sorted per group, removes `next`, `react`, `react-dom`, `eslint-config-next`, `@types/react`, `@types/react-dom`, `server-only`, `client-only`, and `@next/*`, and pins `astro` 7.3.8, `@astrojs/preact` 6.0.6, `preact` 10.29.8, and `react` and `react-dom` as `npm:@preact/compat@18.3.2`, the profile that E2 built. `astro.config.mjs` enables `preact({ compat: true })`; `tsconfig.json` extends `astro/tsconfigs/strict` with Preact's JSX settings; `.gitignore` keeps the source entries and adds `dist/` and `.astro/`. `.frameshift/SUMMARY.md` lists results and tasks, with every source path in an escaped Markdown code span. Generated text uses the dominant line ending of the converted sources. No manifest or report file is written yet. @@ -1949,14 +1949,14 @@ Output paths are validated as `OutputPath` values and compared without ASCII cas **CLI.** `frameshift migrate [] --to astro-preact --out [--package ] [--dry-run]` parses arguments in `crates/frameshift-cli/src/migrate_cli.rs`, calls `plan`, `check_output`, and, without `--dry-run`, `execute`. The dry run prints every operation with its kind, escaped output path, rule, and source; a real run prints counts. Exit statuses: 0, 2 for usage errors (including every planned option), 3 for analysis input and limit failures, 4 for refused or failed output, 6 for stream failures, and 70 for internal errors. Preserved files do not change the status. -**Validation.** `fixtures/migrate/` holds two fictional cases with `expected/plan.txt`, `expected/output/`, and `expected/dry-run.txt`: `static-site` (root and nested layouts, a title template, page metadata, `next/link`, a route group, a dropped segment option, an Astro component, a static Preact component, a relocated module, a global stylesheet, public assets, and a preserved dynamic route and client page) and `fail-closed` (a `src/app` project where preservation propagates from a client component and a request-time module to the pages that import them, a page with a dynamic `import()` of a project file, a page that binds the generated layout name, pages and a component whose function bindings clash with or are read by module declarations, a module that reads `import.meta`, and declaration files with a relative ambient module or a triple-slash path reference are preserved, and an optional dependency is kept); `static-site` also preserves an out-of-scope test file, rewrites type-only alias imports in a module and a declaration file, keeps a wildcard ambient declaration, and converts an async page whose body statements read a later module declaration and a component with a `module-per-render` review. Both expected outputs were built on 2026-11-01 with the pinned `astro7-preact10` profile outside the repository: `static-site` produced 6 pages and `fail-closed` 1 page, without a `script` tag in any page. `crates/frameshift-engine/tests/migrate.rs` plans twice and compares byte for byte, executes into a temporary directory, and checks that the written tree equals the plan, that the source is byte-identical afterwards, that nothing else was created, and that unsafe outputs are refused. `crates/frameshift-cli/tests/migrate.rs` checks the dry-run text, the written tree, and exit statuses. Goldens change only through: +**Validation.** `fixtures/migrate/` holds two fictional cases with `expected/plan.txt`, `expected/output/`, and `expected/dry-run.txt`: `static-site` (root and nested layouts, a title template, page metadata, `next/link`, a route group, a dropped segment option, an Astro component, a static Preact component, a relocated module, a global stylesheet, public assets, and a preserved dynamic route and client page) and `fail-closed` (a `src/app` project where preservation propagates from a client component and a request-time module to the pages that import them, a page with a dynamic `import()` of a project file, a page that binds the generated layout name, pages and a component whose function bindings clash with or are read by module declarations, a module that reads `import.meta`, and declaration files with a relative ambient module or a triple-slash path reference are preserved, and an optional dependency is kept); `static-site` also preserves an out-of-scope test file, rewrites type-only alias imports in a module and a declaration file, keeps a wildcard ambient declaration, and converts an async page whose body statements read a later module declaration, a component with a `module-per-render` review, an arrow page behind `export default `, a page with an expression statement, and an arrow component with a default prop value. Both expected outputs were built on 2026-11-01 with the pinned `astro7-preact10` profile outside the repository: `static-site` produced 8 pages and `fail-closed` 1 page, without a `script` tag in any page. `crates/frameshift-engine/tests/migrate.rs` plans twice and compares byte for byte, executes into a temporary directory, and checks that the written tree equals the plan, that the source is byte-identical afterwards, that nothing else was created, and that unsafe outputs are refused. `crates/frameshift-cli/tests/migrate.rs` checks the dry-run text, the written tree, and exit statuses. Goldens change only through: ```sh cargo test -p frameshift-engine --offline --locked --test migrate -- --ignored update_migrate_goldens cargo test -p frameshift-cli --offline --locked --test migrate -- --ignored update_migrate_text_goldens ``` -**Boundaries.** The analysis' Astro strategy (section 51) covers more than v0 converts, because these rules do not use `convert_component` (section 10) yet: a function with expression statements before its final `return`, an arrow function, or props other than shorthand properties without defaults is preserved with its reason, and so is any page that reads `params`. Islands, dynamic routes, special files other than pages and layouts, route handlers, metadata beyond static `title` and `description`, nested layout metadata, `next/image`, `next/font`, Tailwind CSS, environment variable renaming, and configuration mapping are preserved or listed as tasks. Configuration concerns other than the App Router project, manifest, lockfile, and TypeScript configuration become project tasks. A copied module declaration runs on every render instead of once per module: a layout or component whose module declarations evaluate a call, `new`, `await`, or tagged template becomes a `Review` task (`module-per-render`), while state that a body mutates through an object literal is not detected. +**Boundaries.** The rules convert what the analysis' Astro strategy accepts (section 51), except that any page that reads `params` is preserved with its reason, and so is an anonymous component function, which has no name for its `.astro` file. Islands, dynamic routes, special files other than pages and layouts, route handlers, metadata beyond static `title` and `description`, nested layout metadata, `next/image`, `next/font`, Tailwind CSS, environment variable renaming, and configuration mapping are preserved or listed as tasks. Configuration concerns other than the App Router project, manifest, lockfile, and TypeScript configuration become project tasks. A copied module declaration runs on every render instead of once per module: a layout or component whose module declarations evaluate a call, `new`, `await`, or tagged template becomes a `Review` task (`module-per-render`), while state that a body mutates through an object literal is not detected. ## 53. Plan documents and `frameshift plan` (`D-102`) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 06d4127..546dde5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Each milestone ends with a capability that is demonstrable and tested. | **M4** Semantic analysis and project model (complete) | Facts about any App Router project | Snapshot-only local module resolution, inherited `tsconfig`/`jsconfig` paths, and a deterministic import graph are implemented (`D-091`, IMPLEMENTATION.md section 37). Package `imports`, self-references, and directory entry points with per-edge resolution modes follow (`D-092`, section 38). Scopes, bindings, references, imports, and exports of each file follow (`D-092`, section 39), as do the App Router route tree, convention exports, and render contexts (section 40), and the static evaluator with experiment E3 (section 41). A separate Node10 resolution profile for `"moduleResolution": "node"` is implemented (`D-093`, section 42); ordered module filename suffixes are implemented in both profiles (`D-094`, section 43); the assembled source model with inventory, per-module binding and render evidence, direct environment uses, bounded route closures, and a golden snapshot is implemented (`D-095`, section 44); the component graph resolves render sites through bindings, module edges, and recognized wrappers, and environment classification records where modules run and which variables and runtime APIs they use (`D-096`, sections 45 and 46); `tsconfig`/`jsconfig` inheritance from linked workspace packages is implemented (`D-098`, section 48). Module and component graphs. Route tree. Render contexts. Environment classification. Static evaluator (with experiment E3 measuring evaluation coverage). Next.js configuration, convention-export, and direct-import API facts are implemented (`D-097`, section 47), preserving unknown values and bundler evidence. Golden snapshots of the project model. M4 closed under `D-099` (IMPLEMENTATION.md section 49): custom bundler rules stay configuration evidence because V1 assesses them as `unsupported`, and indirect API value flow and component values from higher-order components and factories stay explicit unknown evidence. | M2, M3 | | **M5** Assessment and analysis report (implemented) | `frameshift analyze`: **first user value** | Feature catalog for the V1 matrix, implemented as embedded data with the detection of feature occurrences and their fixed statuses (`D-099`, IMPLEMENTATION.md section 50). Read-only assessment predicates, counting ownership and accounting, transitive route readiness with witness paths, a versioned knowledge base, corpus-validated `weights-v1`, exact integer metrics, `frameshift.analysis` v1 with bounded stored parsing, text/JSON/Markdown projections, `analyze`, `report`, `explain`, assessment `rules`, and fact-based verdicts are implemented under `D-100` (IMPLEMENTATION.md section 51). Unknown preconditions remain visible; no transformation coverage is claimed. | M4 | | **M6** Planning (implemented) | `frameshift plan` | Configuration file `frameshift.jsonc` with its JSON Schema. Output path mapping for the conventional Astro layout, with collision detection. Rule registry with exclusivity checks. Operation generation. Plan document, hashing, and binding. Manual tasks. `migrate --dry-run` file listing. Migrate v0 (`D-101`) implements the output path mapping with collision detection, operations, manual tasks, and the dry-run listing as a pure function of the analysis, first (IMPLEMENTATION.md section 52). The plan document, its hash and binding, and `frameshift plan` are implemented (`D-102`, section 53), as are the configuration file (`D-103`, section 54) and the rule registry with its exclusivity check (`D-104`, section 55). | M5 | -| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. Migrate v0 (`D-101`) implements staging with an atomic rename and cleanup of its own staging directory, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | +| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. Migrate v0 (`D-101`) implements staging with an atomic rename and cleanup of its own staging directory, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | | **M8** Dynamic routes, assets, styling | Common content sites | Dynamic routes with static parameters (including composition), catch-all routes, root not-found, special files, static GET route handlers, generated metadata routes, `next/image` (both strategies), `next/font`, `next/script`, Tailwind CSS v3 and v4, PostCSS, environment variable rewriting (`Q-003` resolved), metadata helper for non-literal metadata. | M7 | | **M9** Client components and islands | Interactive sites | Island selection, `client:load`, `client:only` for `ssr: false`, the compat strategy with package aliases and overrides for every supported package manager, boundary prop analysis and named slots, navigation helper, context scope classification, hook coverage, knowledge base entries for curated React libraries backed by fixtures. | M7 | | **M10** Verification | `frameshift verify`, including runtime | External tool runner (authorization, temporary copies, environment scrubbing, timeouts, log capture). Build level. Static output level with HTML normalization and per-route comparison (experiment E4 decides the HTML parser, `Q-005`). Runtime hydration, declared interaction, and selected accessibility checks using a user-installed browser. Decide browser tooling and the versioned check protocol here. Per-unit verification evidence. | M7. Full value after M8 and M9. | diff --git a/fixtures/migrate/static-site/expected/dry-run.txt b/fixtures/migrate/static-site/expected/dry-run.txt index 618f8ea..605a4e1 100644 --- a/fixtures/migrate/static-site/expected/dry-run.txt +++ b/fixtures/migrate/static-site/expected/dry-run.txt @@ -9,6 +9,7 @@ config package.json config.package-manifest copy public/paper.svg asset.verbatim from public/paper.svg copy public/robots.txt asset.verbatim from public/robots.txt transform src/components/Badge.tsx component.preact-static from components/Badge.tsx +create src/components/Card.astro component.astro from components/Card.tsx review create src/components/SiteHeader.astro component.astro from components/SiteHeader.tsx review create src/components/Stamp.astro component.astro from components/Stamp.tsx review create src/layouts/DocsLayout.astro layout.chain from app/docs/layout.tsx @@ -16,14 +17,16 @@ create src/layouts/RootLayout.astro layout.chain from app/layout.tsx transform src/lib/site.ts module.relocate from lib/site.ts transform src/lib/types.ts module.relocate from lib/types.ts create src/pages/about.astro route.static-page from app/about/page.tsx +create src/pages/arrow.astro route.static-page from app/arrow/page.tsx create src/pages/changelog.astro route.static-page from app/changelog/page.tsx review create src/pages/docs.astro route.static-page from app/docs/page.tsx create src/pages/docs/intro.astro route.static-page from app/docs/intro/page.tsx create src/pages/index.astro route.static-page from app/page.tsx review create src/pages/pricing.astro route.static-page from app/(marketing)/pricing/page.tsx +create src/pages/statements.astro route.static-page from app/statements/page.tsx copy src/styles/globals.css style.relocate from app/globals.css transform src/types/api.d.ts module.relocate from types/api.d.ts transform src/types/assets.d.ts module.relocate from types/assets.d.ts config tsconfig.json config.tsconfig -26 operations planned (10 converted to Astro, 3 preserved); 0 files written. -Manual tasks: 10. See .frameshift/SUMMARY.md in the output directory. +29 operations planned (13 converted to Astro, 3 preserved); 0 files written. +Manual tasks: 11. See .frameshift/SUMMARY.md in the output directory. diff --git a/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md b/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md index 6a5f6fa..5a675f3 100644 --- a/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md +++ b/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md @@ -6,7 +6,7 @@ Migrate v0 converts only static pages, layouts, and components that the analysis ## Results -- Converted to Astro components: 10 +- Converted to Astro components: 13 - Kept as JavaScript or TypeScript with rewritten imports: 5 - Copied unchanged: 3 - Generated project files: 5 @@ -29,6 +29,7 @@ Migrate v0 converts only static pages, layouts, and components that the analysis - `app/changelog/page.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) - `app/page.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) +- `components/Card.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization) - `components/SiteHeader.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) - `components/Stamp.tsx`: converted; check the result, because the conversion relies on assumptions that a review must confirm (module-per-render) diff --git a/fixtures/migrate/static-site/expected/output/src/components/Card.astro b/fixtures/migrate/static-site/expected/output/src/components/Card.astro new file mode 100644 index 0000000..688f511 --- /dev/null +++ b/fixtures/migrate/static-site/expected/output/src/components/Card.astro @@ -0,0 +1,7 @@ +--- +const { title, tone = 'plain' } = Astro.props; +const label = `${title} (${tone})`; +--- +
+ {`${label}`} +
diff --git a/fixtures/migrate/static-site/expected/output/src/pages/arrow.astro b/fixtures/migrate/static-site/expected/output/src/pages/arrow.astro new file mode 100644 index 0000000..2df3f8b --- /dev/null +++ b/fixtures/migrate/static-site/expected/output/src/pages/arrow.astro @@ -0,0 +1,10 @@ +--- +import Card from '../components/Card.astro'; +import RootLayout from '../layouts/RootLayout.astro'; +--- + +
+ + +
+
diff --git a/fixtures/migrate/static-site/expected/output/src/pages/statements.astro b/fixtures/migrate/static-site/expected/output/src/pages/statements.astro new file mode 100644 index 0000000..4ff9bd0 --- /dev/null +++ b/fixtures/migrate/static-site/expected/output/src/pages/statements.astro @@ -0,0 +1,14 @@ +--- +import RootLayout from '../layouts/RootLayout.astro'; + +const items = ['first', 'second']; +// An expression statement keeps its place before the markup. +items.reverse(); +--- + +
    + {items.map((item) => ( +
  • {`${item}`}
  • + ))} +
+
diff --git a/fixtures/migrate/static-site/expected/plan.json b/fixtures/migrate/static-site/expected/plan.json index c3dae5a..9be58a0 100644 --- a/fixtures/migrate/static-site/expected/plan.json +++ b/fixtures/migrate/static-site/expected/plan.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "planHash": "@PLAN_HASH@", "binding": { - "snapshot": "sha256:c6038f37662573e1670fc1f27fc91c0b35e79fe50788b588ffed285a3d047ba0", + "snapshot": "sha256:392a22ad93d5e9a2d85f8c0859ed816dcc7134bf2562e2930a4eaba5ca08f40c", "engineVersion": "@ENGINE_VERSION@", "ruleset": "nextjs-to-astro-preact/assessment-v1", "knowledgeBases": [ @@ -27,8 +27,8 @@ "ruleVersion": 1, "source": null, "review": false, - "size": 2322, - "sha256": "sha256:c91dead2bc92213e44b6d74b8d995657f654455c0e8daeb1f61c46b517c8c717" + "size": 2514, + "sha256": "sha256:5b96fddcbf6a7c4b4cde7c833dc415ca301ef0e3210451c96c378260a1e797ea" }, { "kind": "preserve", @@ -120,6 +120,16 @@ "size": 142, "sha256": "sha256:eaba3e84ff925cc34ba379b7996516a5c7ad4309767d6f3561eecb77a76ed356" }, + { + "kind": "create", + "output": "src/components/Card.astro", + "rule": "component.astro", + "ruleVersion": 1, + "source": "components/Card.tsx", + "review": true, + "size": 140, + "sha256": "sha256:9decbb8f327c8f1fe90a3982f45a463b0a27125541cb6eaf2608eda4673c2cd3" + }, { "kind": "create", "output": "src/components/SiteHeader.astro", @@ -190,6 +200,16 @@ "size": 302, "sha256": "sha256:68aaa7eae4225a398a79598f817a8f12de8586cb048de98fec567e17cf468bfb" }, + { + "kind": "create", + "output": "src/pages/arrow.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/arrow/page.tsx", + "review": false, + "size": 305, + "sha256": "sha256:af69d25b981c0403f0f5987e8c6db6e10a72dadcd91c6325295d8bfde909fa50" + }, { "kind": "create", "output": "src/pages/changelog.astro", @@ -240,6 +260,16 @@ "size": 221, "sha256": "sha256:eb0be37f6c3c378e30f6c55769076cab8016b8c8237a2bcadc8fd1163e6404a3" }, + { + "kind": "create", + "output": "src/pages/statements.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/statements/page.tsx", + "review": false, + "size": 367, + "sha256": "sha256:4b4f039977c24238866718114a89e38267ee054d2399cf987193d5b835ff4934" + }, { "kind": "copy", "output": "src/styles/globals.css", @@ -322,6 +352,11 @@ "source": "app/page.tsx", "text": "converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true)" }, + { + "kind": "review", + "source": "components/Card.tsx", + "text": "converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization)" + }, { "kind": "review", "source": "components/SiteHeader.tsx", diff --git a/fixtures/migrate/static-site/expected/plan.txt b/fixtures/migrate/static-site/expected/plan.txt index 15051a0..f72c59e 100644 --- a/fixtures/migrate/static-site/expected/plan.txt +++ b/fixtures/migrate/static-site/expected/plan.txt @@ -8,6 +8,7 @@ config package.json config.package-manifest - copy public/paper.svg asset.verbatim public/paper.svg copy public/robots.txt asset.verbatim public/robots.txt transform src/components/Badge.tsx component.preact-static components/Badge.tsx +create src/components/Card.astro component.astro components/Card.tsx review create src/components/SiteHeader.astro component.astro components/SiteHeader.tsx review create src/components/Stamp.astro component.astro components/Stamp.tsx review create src/layouts/DocsLayout.astro layout.chain app/docs/layout.tsx @@ -15,11 +16,13 @@ create src/layouts/RootLayout.astro layout.chain app/layout.tsx transform src/lib/site.ts module.relocate lib/site.ts transform src/lib/types.ts module.relocate lib/types.ts create src/pages/about.astro route.static-page app/about/page.tsx +create src/pages/arrow.astro route.static-page app/arrow/page.tsx create src/pages/changelog.astro route.static-page app/changelog/page.tsx review create src/pages/docs.astro route.static-page app/docs/page.tsx create src/pages/docs/intro.astro route.static-page app/docs/intro/page.tsx create src/pages/index.astro route.static-page app/page.tsx review create src/pages/pricing.astro route.static-page app/(marketing)/pricing/page.tsx +create src/pages/statements.astro route.static-page app/statements/page.tsx copy src/styles/globals.css style.relocate app/globals.css transform src/types/api.d.ts module.relocate types/api.d.ts transform src/types/assets.d.ts module.relocate types/assets.d.ts @@ -33,6 +36,7 @@ task Preserved app/contact/page.tsx: preserved for manual migration: client comp task Preserved tests/site.test.ts: preserved for manual migration: tests and repository tooling are outside V1 task Review app/changelog/page.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) task Review app/page.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) +task Review components/Card.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization) task Review components/SiteHeader.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) task Review components/Stamp.tsx: converted; check the result, because the conversion relies on assumptions that a review must confirm (module-per-render) task NotCopied next.config.mjs: replaced by the generated astro.config.mjs diff --git a/fixtures/migrate/static-site/fixture.json b/fixtures/migrate/static-site/fixture.json index fe63ee1..4734932 100644 --- a/fixtures/migrate/static-site/fixture.json +++ b/fixtures/migrate/static-site/fixture.json @@ -1,7 +1,7 @@ { "$schema": "../../../schemas/fixture.schema.json", "kind": "migrate", - "description": "A fictional static App Router site for migrate v0 (D-101): a root layout with a title template, description, global stylesheet, and an Astro-convertible header component; static pages with next/link, page metadata, a dropped segment option, a route group, a nested layout, a static Preact component with an early return, an Astro component whose module declaration evaluates a call and so needs review, a relocated library module and declaration file whose type-only aliased imports are rewritten, a wildcard ambient declaration, an async page with body statements that read a later module binding, and public assets. A test file is preserved as out of scope. A dynamic route without generateStaticParams and a client page are preserved. The manifest names the real next package, so the harness creates it at test time (Q-014).", + "description": "A fictional static App Router site for migrate v0 (D-101): a root layout with a title template, description, global stylesheet, and an Astro-convertible header component; static pages with next/link, page metadata, a dropped segment option, a route group, a nested layout, a static Preact component with an early return, an Astro component whose module declaration evaluates a call and so needs review, an arrow page behind a default export name, a page with an expression statement, an arrow component with a default prop value, a relocated library module and declaration file whose type-only aliased imports are rewritten, a wildcard ambient declaration, an async page with body statements that read a later module binding, and public assets. A test file is preserved as out of scope. A dynamic route without generateStaticParams and a client page are preserved. The manifest names the real next package, so the harness creates it at test time (Q-014).", "createdAtTestTime": [ { "kind": "file", diff --git a/fixtures/migrate/static-site/source/app/arrow/page.tsx b/fixtures/migrate/static-site/source/app/arrow/page.tsx new file mode 100644 index 0000000..6adeda1 --- /dev/null +++ b/fixtures/migrate/static-site/source/app/arrow/page.tsx @@ -0,0 +1,12 @@ +import { Card } from '@/components/Card'; + +export const metadata = { title: 'Arrow' }; + +const ArrowPage = () => ( +
+ + +
+); + +export default ArrowPage; diff --git a/fixtures/migrate/static-site/source/app/statements/page.tsx b/fixtures/migrate/static-site/source/app/statements/page.tsx new file mode 100644 index 0000000..a05821d --- /dev/null +++ b/fixtures/migrate/static-site/source/app/statements/page.tsx @@ -0,0 +1,14 @@ +export const metadata = { title: 'Statements' }; + +export default function StatementsPage() { + const items = ['first', 'second']; + // An expression statement keeps its place before the markup. + items.reverse(); + return ( +
    + {items.map((item) => ( +
  • {`${item}`}
  • + ))} +
+ ); +} diff --git a/fixtures/migrate/static-site/source/components/Card.tsx b/fixtures/migrate/static-site/source/components/Card.tsx new file mode 100644 index 0000000..2be536d --- /dev/null +++ b/fixtures/migrate/static-site/source/components/Card.tsx @@ -0,0 +1,4 @@ +export const Card = ({ title, tone = 'plain' }: { title: string; tone?: string }) => { + const label = `${title} (${tone})`; + return
{`${label}`}
; +}; From cfe9f8907d1f357055305f21bb2c510fcc7f4d81 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:32:30 +0100 Subject: [PATCH 02/11] feat(migrate): write the frameshift.manifest provenance record Every migration now writes .frameshift/manifest.json with the plan hash, the binding, and the origin and digest of every other output file (D-105). It is the base for replace-managed output and verification. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- crates/frameshift-cli/tests/migrate.rs | 22 +- crates/frameshift-engine/src/migrate/mod.rs | 20 +- .../src/migrate/plan_document.rs | 27 +- crates/frameshift-engine/src/migrate/rules.rs | 5 + crates/frameshift-engine/tests/migrate.rs | 69 +++- docs/DECISIONS.md | 9 + docs/ENGINEERING.md | 2 +- docs/IMPLEMENTATION.md | 6 +- docs/ROADMAP.md | 2 +- .../migrate/fail-closed/expected/dry-run.txt | 3 +- .../expected/output/.frameshift/manifest.json | 234 +++++++++++++ .../migrate/fail-closed/expected/plan.txt | 1 + .../migrate/static-site/expected/dry-run.txt | 3 +- .../expected/output/.frameshift/manifest.json | 314 ++++++++++++++++++ .../migrate/static-site/expected/plan.txt | 1 + schemas/frameshift.manifest.schema.json | 15 + 17 files changed, 710 insertions(+), 25 deletions(-) create mode 100644 fixtures/migrate/fail-closed/expected/output/.frameshift/manifest.json create mode 100644 fixtures/migrate/static-site/expected/output/.frameshift/manifest.json create mode 100644 schemas/frameshift.manifest.schema.json diff --git a/AGENTS.md b/AGENTS.md index b4b5138..2382efa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ These instructions are binding for every agent working on Frameshift, whatever t - **M5 analysis (`D-100`).** `frameshift_engine::analysis` owns read-only assessment, counting/file accounting, transitive readiness, knowledge, exact metrics, and `frameshift.analysis` v1. Reuse `project::build_with`; no identifier-spelling inference or source execution. Unknown preconditions stay unknown, and assessment predicates never count as transformation coverage. Stored reports are bounded, validated data and never authorize reading their paths or executing operations. The CLI exposes `analyze`, stored `report`, `explain`, and assessment `rules`; explicit analysis report outputs are new files outside the source. Golden updates use `cargo test -p frameshift-engine --offline --locked --test analysis -- --ignored update_analysis_golden`. Corpus and reference-fixture commands, limits, current inference boundaries, and validated weights are in IMPLEMENTATION.md section 51. -- **Migrate v0 (`D-101`).** `frameshift_engine::migrate` plans only from `analysis::analyze_parts` (the analysis plus its snapshot, project model, and manifest); never read the source again or write outside `frameshift-output`. A conversion that moves bindings into the frontmatter scope must check them against that scope, and a relocation must refuse what the module graph has no edge for. A stored plan document is only compared with the recomputed one, never parsed or interpreted (`D-102`). Every operation names a rule in `migrate/rules.rs` of its own kind, and one source file reaches one rule; raise a rule's version whenever its output can change (`D-104`). Convert only units with status `auto` or `review` and strategy `astro` or `preact-static`; preserve everything else below `.frameshift/unmigrated/` with a manual task, and keep the preservation fixed point so the output never imports a file that was not written. Every rule refusal is a preserved file with a reason, never a guessed conversion. Fixtures are in `fixtures/migrate/` (IMPLEMENTATION.md section 52). +- **Migrate v0 (`D-101`).** `frameshift_engine::migrate` plans only from `analysis::analyze_parts` (the analysis plus its snapshot, project model, and manifest); never read the source again or write outside `frameshift-output`. A conversion that moves bindings into the frontmatter scope must check them against that scope, and a relocation must refuse what the module graph has no edge for. A stored plan document is only compared with the recomputed one, never parsed or interpreted (`D-102`). Every operation names a rule in `migrate/rules.rs` of its own kind, and one source file reaches one rule; raise a rule's version whenever its output can change (`D-104`). The manifest depends on the plan hash, so it stays outside the plan document (`D-105`). Convert only units with status `auto` or `review` and strategy `astro` or `preact-static`; preserve everything else below `.frameshift/unmigrated/` with a manual task, and keep the preservation fixed point so the output never imports a file that was not written. Every rule refusal is a preserved file with a reason, never a guessed conversion. Fixtures are in `fixtures/migrate/` (IMPLEMENTATION.md section 52). - **Component graph (`D-096`).** `frameshift_engine::project::Project::components` resolves render sites through bindings and the module graph's own edges (IMPLEMENTATION.md section 45). Recognize wrappers (`memo`, `forwardRef`, `lazy`, `next/dynamic`) only through binding provenance of their imports, confirmed by the import edge's package resolution, never by callee spelling; every other call is a `Call` reason. Follow `export *` with ECMAScript's rules and keep several or unknowable providers `AmbiguousStar`. Linking charges `modules.max-work` and returns no partial graph. Its golden changes only through `cargo test -p frameshift-engine --offline --locked --test components -- --ignored update_components_golden`. diff --git a/crates/frameshift-cli/tests/migrate.rs b/crates/frameshift-cli/tests/migrate.rs index ecc5b42..88c71ee 100644 --- a/crates/frameshift-cli/tests/migrate.rs +++ b/crates/frameshift-cli/tests/migrate.rs @@ -76,6 +76,20 @@ fn workspace(case: &str, label: &str) -> PathBuf { root } +// The manifest holds release-dependent members; the golden has placeholders. +fn normalize_manifest(files: &mut BTreeMap>) { + let key = ".frameshift/manifest.json"; + let text = String::from_utf8(files[key].clone()).unwrap(); + let start = text.find("\"planHash\": \"").unwrap() + "\"planHash\": \"".len(); + let hash = &text[start..start + text[start..].find('"').unwrap()]; + let version = frameshift_engine::version::info().engine_version; + let text = text.replace(hash, "@PLAN_HASH@").replace( + &format!("\"engineVersion\": \"{version}\""), + "\"engineVersion\": \"@ENGINE_VERSION@\"", + ); + files.insert(key.to_owned(), text.into_bytes()); +} + fn call(root: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_frameshift")) .current_dir(root) @@ -115,7 +129,9 @@ fn migration_writes_the_golden_tree_and_refuses_unsafe_outputs() { assert_eq!(output.status.code(), Some(0), "{case}"); assert!(output.stdout.starts_with(b"Migrated: ")); let golden = read_tree(&fixtures().join(&case).join("expected/output")); - assert_eq!(read_tree(&root.join("out")), golden, "{case}"); + let mut written = read_tree(&root.join("out")); + normalize_manifest(&mut written); + assert_eq!(written, golden, "{case}"); // An existing output and an output inside the source are refused. for out in ["out", "source/out"] { let args = ["migrate", "source", "--to", "astro-preact", "--out", out]; @@ -256,7 +272,9 @@ fn plan_documents_approve_exactly_the_run_they_describe() { assert_eq!(verified.status.code(), Some(0), "{case}"); assert!(String::from_utf8_lossy(&verified.stdout).starts_with("Plan ")); let golden = read_tree(&fixtures().join(&case).join("expected/output")); - assert_eq!(read_tree(&root.join("verified")), golden, "{case}"); + let mut written = read_tree(&root.join("verified")); + normalize_manifest(&mut written); + assert_eq!(written, golden, "{case}"); fs::write(root.join("source/added.txt"), "changed").unwrap(); let stale = approved("stale", "plan.json"); diff --git a/crates/frameshift-engine/src/migrate/mod.rs b/crates/frameshift-engine/src/migrate/mod.rs index 92df891..7bf12e8 100644 --- a/crates/frameshift-engine/src/migrate/mod.rs +++ b/crates/frameshift-engine/src/migrate/mod.rs @@ -27,7 +27,7 @@ use crate::analysis::{self, AnalyzeErrorKind, AnalyzeOptions, FileClass, Strateg use crate::limits::Limits; use crate::project::FileState; use convert::{Context, Role as AstroRole, RootFacts, Target, TargetKind}; -pub use plan_document::{Binding, PLAN_SCHEMA_VERSION}; +pub use plan_document::{Binding, MANIFEST_SCHEMA_VERSION, PLAN_SCHEMA_VERSION}; pub use frameshift_output::Destination; @@ -40,6 +40,9 @@ pub const UNMIGRATED: &str = ".frameshift/unmigrated"; /// Path of the human-readable summary in the output. pub const SUMMARY: &str = ".frameshift/SUMMARY.md"; +/// The provenance record that every migration writes. +pub const MANIFEST: &str = ".frameshift/manifest.json"; + /// Explicit inputs to one migration plan. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MigrateOptions { @@ -1115,6 +1118,21 @@ impl<'a> Planner<'a> { package: self.package, }; let printed = plan_document::print(&binding, &operations, &tasks, self.limits)?; + // The insertion point keeps the operations sorted by output path. + let manifest = output(MANIFEST)?; + let index = operations + .partition_point(|o| o.output.as_str().as_bytes() < manifest.as_str().as_bytes()); + operations.insert( + index, + Operation { + kind: OperationKind::Config, + output: manifest, + rule: "report.manifest", + source: None, + review: false, + bytes: printed.manifest, + }, + ); Ok(Plan { operations, tasks, diff --git a/crates/frameshift-engine/src/migrate/plan_document.rs b/crates/frameshift-engine/src/migrate/plan_document.rs index 7ed6212..1968b64 100644 --- a/crates/frameshift-engine/src/migrate/plan_document.rs +++ b/crates/frameshift-engine/src/migrate/plan_document.rs @@ -15,6 +15,9 @@ use crate::limits::Limits; /// The version of the `frameshift.plan` document. pub const PLAN_SCHEMA_VERSION: u64 = 1; +/// The version of the `frameshift.manifest` document. +pub const MANIFEST_SCHEMA_VERSION: u64 = 1; + // Starts the hashed stream, so this hash can equal no other Frameshift digest. const HASH_DOMAIN: &[u8] = b"frameshift.plan.v1\n"; @@ -37,10 +40,11 @@ pub struct Binding { pub package: Option, } -/// The hash and the printed document of one plan. +/// The hash, the printed document, and the manifest of one plan. pub(super) struct Printed { pub(super) hash: Sha256Digest, pub(super) document: String, + pub(super) manifest: Vec, } fn too_large() -> MigrateError { @@ -121,8 +125,8 @@ pub(super) fn print( ("schemaVersion", V::Number(PLAN_SCHEMA_VERSION)), ]; let content = [ - ("binding", binding), - ("operations", V::Array(entries)), + ("binding", binding.clone()), + ("operations", V::Array(entries.clone())), ("tasks", V::Array(tasks)), ]; let max = limits.analysis.max_report_bytes; @@ -140,5 +144,20 @@ pub(super) fn print( .cloned() .collect(); let document = document::print_bounded(&V::Object(members), max).ok_or_else(too_large)?; - Ok(Printed { hash, document }) + // The manifest names the plan by its hash, so the plan cannot cover it. + let manifest = V::Object(vec![ + ("kind", V::string("frameshift.manifest")), + ("schemaVersion", V::Number(MANIFEST_SCHEMA_VERSION)), + member, + ("binding", binding), + ("files", V::Array(entries)), + ]); + let manifest = document::print_bounded(&manifest, max) + .ok_or_else(too_large)? + .into_bytes(); + Ok(Printed { + hash, + document, + manifest, + }) } diff --git a/crates/frameshift-engine/src/migrate/rules.rs b/crates/frameshift-engine/src/migrate/rules.rs index 7f371ba..5b4f573 100644 --- a/crates/frameshift-engine/src/migrate/rules.rs +++ b/crates/frameshift-engine/src/migrate/rules.rs @@ -87,6 +87,11 @@ pub const RULES: &[Rule] = &[ OperationKind::Preserve, "Copies an unmigrated file below .frameshift/unmigrated/ and adds a manual task.", ), + rule( + "report.manifest", + OperationKind::Config, + "Generates .frameshift/manifest.json with the versions, plan hash, and file digests.", + ), rule( "report.summary", OperationKind::Config, diff --git a/crates/frameshift-engine/tests/migrate.rs b/crates/frameshift-engine/tests/migrate.rs index f0c649f..44322dc 100644 --- a/crates/frameshift-engine/tests/migrate.rs +++ b/crates/frameshift-engine/tests/migrate.rs @@ -161,18 +161,33 @@ fn outputs(plan: &Plan) -> BTreeMap> { .collect() } -/// The plan document with its release-dependent members replaced, because -/// the engine version is hashed and changes with every release. +/// A plan or manifest document with its release-dependent members replaced, +/// because the engine version is hashed and changes with every release. +fn normalized(text: &str, plan: &Plan) -> String { + text.replace( + &format!( + "\"engineVersion\": \"{}\"", + frameshift_engine::version::info().engine_version + ), + "\"engineVersion\": \"@ENGINE_VERSION@\"", + ) + .replace(&plan.hash().to_string(), "@PLAN_HASH@") +} + fn document(plan: &Plan) -> String { - plan.document() - .replace( - &format!( - "\"engineVersion\": \"{}\"", - frameshift_engine::version::info().engine_version - ), - "\"engineVersion\": \"@ENGINE_VERSION@\"", - ) - .replace(&plan.hash().to_string(), "@PLAN_HASH@") + normalized(plan.document(), plan) +} + +/// The golden form of the output files: everything byte for byte, except +/// the manifest, which holds the release-dependent members. +fn golden_outputs(plan: &Plan) -> BTreeMap> { + let mut files = outputs(plan); + let manifest = String::from_utf8(files[migrate::MANIFEST].clone()).unwrap(); + files.insert( + migrate::MANIFEST.to_owned(), + normalized(&manifest, plan).into_bytes(), + ); + files } fn run(case: &Case, label: &str) -> (String, String, BTreeMap>) { @@ -183,7 +198,7 @@ fn run(case: &Case, label: &str) -> (String, String, BTreeMap>) let second = migrate::plan(&source, &MigrateOptions::default()).unwrap(); assert_eq!(first, second, "{}: repeated plans differ", case.name); fs::remove_dir_all(workspace).unwrap(); - (listing(&first), document(&first), outputs(&first)) + (listing(&first), document(&first), golden_outputs(&first)) } #[test] @@ -248,6 +263,36 @@ fn execution_writes_exactly_the_plan_and_never_touches_the_source() { } } +#[test] +fn manifests_record_the_plan_and_every_other_output_file() { + for case in cases() { + let workspace = prepare(&case, "manifest"); + let source = workspace.join("source"); + let plan = migrate::plan(&source, &MigrateOptions::default()).unwrap(); + let manifest = outputs(&plan).remove(migrate::MANIFEST).unwrap(); + let manifest = String::from_utf8(manifest).unwrap(); + assert!(manifest.contains(&plan.hash().to_string()), "{}", case.name); + for operation in &plan.operations { + let listed = + manifest.contains(&format!("\"output\": \"{}\"", operation.output.as_str())); + assert_eq!( + listed, + operation.output.as_str() != migrate::MANIFEST, + "{}: {}", + case.name, + operation.output.as_str() + ); + } + // The plan document never lists the manifest, which depends on its hash. + assert!( + !plan.document().contains(migrate::MANIFEST), + "{}", + case.name + ); + fs::remove_dir_all(workspace).unwrap(); + } +} + #[test] fn the_rule_registry_is_sorted_unique_and_catalog_spelled() { let rules = migrate::rules::RULES; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5b8b96e..46cda3e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -112,6 +112,7 @@ Statuses change through an edit to this file under the integration workflow in D | D-102 | M6 plan document, plan hash, and `frameshift plan` | Accepted | | D-103 | The key set of `frameshift.jsonc` | Accepted | | D-104 | The rule registry of migrate | Accepted | +| D-105 | The output manifest | Accepted | | P-001 | Go as the single implementation language, CGO disabled | Superseded by D-018 | | P-002 | Purpose-built parsers | Accepted | | P-003 | Span-based edits and targeted printers; no concrete syntax tree initially | Accepted | @@ -1209,6 +1210,14 @@ Statuses change through an edit to this file under the integration workflow in D - **Tradeoffs:** Selection logic is not data yet, so `rules` lists what exists without its preconditions, and rule coverage metrics stay at their current meaning. - **Revisit if:** M8 or M9 adds rules that can match the same occurrence. Then preconditions and the `excludes` relation become data. +### D-105: The output manifest + +- **Status:** Accepted (2026-11-01), under the owner's instruction to continue the roadmap with M7. +- **Decision:** Every migration writes `.frameshift/manifest.json`, a `frameshift.manifest` v1 document ([schema](../schemas/frameshift.manifest.schema.json)) with the plan hash, the plan's binding, and one entry per other output file: kind, path, rule and version, source, review flag, size, and SHA-256. It is an operation of the plan with the rule `report.manifest`, but it is outside the plan document and the plan hash, because it names that hash. +- **Rationale:** The document proves where every output file came from and is the base for `--on-existing replace-managed` and `verify`. Reusing the plan's entries keeps one definition of what an output file is. +- **Tradeoffs:** A manifest holds no timestamps or absolute paths, so it stays deterministic but cannot tell when a run happened. Source spans and output regions from the provenance model are not recorded yet. +- **Revisit if:** `verify` needs per-region provenance. + ## Technique proposals and related accepted decisions diff --git a/docs/ENGINEERING.md b/docs/ENGINEERING.md index c28df32..ffd3cc0 100644 --- a/docs/ENGINEERING.md +++ b/docs/ENGINEERING.md @@ -385,7 +385,7 @@ Bounded worker pools, cooperative cancellation, no shared mutable state between | Executable and command | `frameshift` (`frameshift.exe` on Windows) | | Repository | `eiserv/frameshift` (`D-026`); public preparation under `D-066` | | Rust crates | A Cargo workspace. Crates are named `frameshift-` (for example `frameshift-engine`, `frameshift-cli`); a source adapter crate is named after its adapter ID (`frameshift-nextjs`, `D-074`). The binary is `frameshift`. Development-only tools live under `tools/`, are named `frameshift-`, and are never dependencies of product crates. Modules inside crates use short, lowercase names. The existing M0 crate layout is recorded in [IMPLEMENTATION.md](IMPLEMENTATION.md); it expands as concrete M1 requirements arise. | -| JSON document kinds | `frameshift.version` and usage `frameshift.error` implemented (`D-050`); `frameshift.analysis` implemented (`D-100`); `frameshift.plan` implemented (`D-102`); `frameshift.report` and `frameshift.manifest` planned | +| JSON document kinds | `frameshift.version` and usage `frameshift.error` implemented (`D-050`); `frameshift.analysis` implemented (`D-100`); `frameshift.plan` implemented (`D-102`); `frameshift.manifest` implemented (`D-105`); `frameshift.report` planned | | Output metadata | `.frameshift/` directory (with `SUMMARY.md`, `manifest.json`, `report.json`, `unmigrated/`), `src/frameshift-compat/` helpers | | Configuration file | `frameshift.jsonc` (`D-027`) | | Environment variable prefix | `FRAMESHIFT_` (no public runtime options defined). `FRAMESHIFT_RUSTC_VERSION` is private Cargo-injected compiler metadata (`D-050`). `FRAMESHIFT_BUILD_COMMIT` is an optional validated build input (`D-051`). Neither is read at runtime. Repository variables prefixed `FRAMESHIFT_` control release automation only. | diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index ef27bce..60c2549 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1943,7 +1943,7 @@ Output paths are validated as `OutputPath` values and compared without ASCII cas **Rules** (`migrate/convert.rs`, promoted from the E2 driver of section 11). A converted page, layout, or component may contain imports, type-only imports (dropped), module-level `const` declarations and function declarations without JSX (copied into the frontmatter), `export const metadata` with static `title` and `description` (a root layout may use a `default`/`template` title), `export const dynamic` with `auto`, `force-static`, or `error` and `export const runtime` (dropped), and one component function: an exported function declaration, an exported `const` that holds a function (for components), or a function or `const` that `export default ` names. The function goes through `convert_component` (section 10), so its body is whatever that conversion accepts. Pages take no props; layouts take only `children`; a component's whole object pattern becomes `const = Astro.props;`, and `children` becomes ``. Every import resolves through its exact module-graph edge: a local target must be written by the plan (a converted component is imported as the default export of its `.astro` file, other targets keep their bindings with a relative specifier), `next/link` becomes `
` under E2's link checks, `server-only` is dropped, and other `next`, `react`, and `react-dom` imports refuse the file. Any other runtime edge of a converted file to a local or unresolved target (a dynamic `import()`, a re-export, or a `require` in copied code) refuses it, because only static import declarations are rewritten. The root layout must render one `html` element without a custom `head`; the generated head holds the charset and viewport tags and the metadata props. A nested layout wraps its parent layout and forwards the metadata props. A page renders its nearest layout with the title and description that Next.js would resolve against the root layout. A file that already binds a name the conversion declares (the layout component of a page, the parent layout and forwarded props of a nested layout, or a root layout's metadata props) is refused. A function's props and body bindings move into the frontmatter scope behind the copied module declarations, so a clash with an import, a module declaration, or each other, a copied module declaration that spells one of those names, and any binding named `Astro` refuse the file. Every comment must reach the output, and emitted provenance is verified. Kept scripts, including declaration files, receive span edits that rewrite each local specifier, including type-only imports and import types, to a relative output specifier (extensionless for JavaScript and TypeScript targets) and remove `import 'server-only'`; imports of `next` or `react-dom`, unresolved edges, imports of converted Astro files, `import.meta`, ambient modules with a non-wildcard name, and triple-slash path references refuse them, because the module graph has no edge for the last three. A stylesheet is relocated only when the CSS tokenizer of section 33 reports no error and, on its tokens, every URL token and every string in `url()`, `src()`, `image()`, or `image-set()` is root-absolute, a data URL, a remote URL, or a fragment other than `#{`, and every `@import`, `@use`, or `@forward` target is a remote or data URL. Comments are separate tokens, so they cannot hide a relative reference. Path aliases are not retargeted; all rewritten specifiers are relative. -**Scaffold** (`migrate/scaffold.rs`). `package.json` keeps the source name and the other `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`, sorted per group, removes `next`, `react`, `react-dom`, `eslint-config-next`, `@types/react`, `@types/react-dom`, `server-only`, `client-only`, and `@next/*`, and pins `astro` 7.3.8, `@astrojs/preact` 6.0.6, `preact` 10.29.8, and `react` and `react-dom` as `npm:@preact/compat@18.3.2`, the profile that E2 built. `astro.config.mjs` enables `preact({ compat: true })`; `tsconfig.json` extends `astro/tsconfigs/strict` with Preact's JSX settings; `.gitignore` keeps the source entries and adds `dist/` and `.astro/`. `.frameshift/SUMMARY.md` lists results and tasks, with every source path in an escaped Markdown code span. Generated text uses the dominant line ending of the converted sources. No manifest or report file is written yet. +**Scaffold** (`migrate/scaffold.rs`). `package.json` keeps the source name and the other `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`, sorted per group, removes `next`, `react`, `react-dom`, `eslint-config-next`, `@types/react`, `@types/react-dom`, `server-only`, `client-only`, and `@next/*`, and pins `astro` 7.3.8, `@astrojs/preact` 6.0.6, `preact` 10.29.8, and `react` and `react-dom` as `npm:@preact/compat@18.3.2`, the profile that E2 built. `astro.config.mjs` enables `preact({ compat: true })`; `tsconfig.json` extends `astro/tsconfigs/strict` with Preact's JSX settings; `.gitignore` keeps the source entries and adds `dist/` and `.astro/`. `.frameshift/SUMMARY.md` lists results and tasks, with every source path in an escaped Markdown code span. Generated text uses the dominant line ending of the converted sources. A manifest is written (section 56); no report file is written yet. **Execution** (`crates/frameshift-output`, the only product crate that writes files). `prepare` canonicalizes the source and the output's parent, refuses an existing output, a missing parent, an output inside the source or containing it, and an existing staging directory `..frameshift-staging` next to the output. `write` creates that staging directory with a `.frameshift-staging` marker, creates every file with `create_new` and `sync_all`, removes the marker, checks that the output is still absent, and renames the staging directory into place. On Windows, an access-denied rename is retried up to 20 times with growing delays, because scanners can briefly hold the new files open; other errors fail at once. On any failure it removes only the staging directory it created. Errors never contain paths. @@ -1985,3 +1985,7 @@ The hash is SHA-256 of the domain line `frameshift.plan.v1` and the printed docu `migrate/rules.rs` lists every transformation rule of migrate with its version, operation kind, and summary. `Planner::finish` rejects an operation whose rule is unregistered or of another kind, and two operations for one source file; both are internal errors, which fail closed. The plan document records `ruleVersion` for each operation. `frameshift rules` prints the rules after the assessment predicates, except with `--unsupported`, and `explain` and `rules show` print one rule by its full identifier, such as `nextjs-to-astro-preact/route.static-page`. Tests check that the registry is sorted and unique, that every identifier passes `RuleId`'s grammar, and that every fixture's plan passes the checks. With this section, M6's scope exists; the preconditions, `excludes` relation, and source spans of ARCHITECTURE.md section 12 remain planned until rules can compete for one occurrence. + +## 56. Output manifest (`D-105`) + +`plan_document::print` also renders the `frameshift.manifest` document, and `Planner::finish` inserts it as the operation `.frameshift/manifest.json` with the rule `report.manifest`, keeping operations sorted by output path. The plan document and hash cover every other operation; the manifest names that hash, so it cannot be part of it. Goldens replace the engine version and the plan hash with placeholders. The manifest lists no timestamps or absolute paths. `--on-existing replace-managed` and `verify`, which will read it, remain planned. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 546dde5..ebe189e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Each milestone ends with a capability that is demonstrable and tested. | **M4** Semantic analysis and project model (complete) | Facts about any App Router project | Snapshot-only local module resolution, inherited `tsconfig`/`jsconfig` paths, and a deterministic import graph are implemented (`D-091`, IMPLEMENTATION.md section 37). Package `imports`, self-references, and directory entry points with per-edge resolution modes follow (`D-092`, section 38). Scopes, bindings, references, imports, and exports of each file follow (`D-092`, section 39), as do the App Router route tree, convention exports, and render contexts (section 40), and the static evaluator with experiment E3 (section 41). A separate Node10 resolution profile for `"moduleResolution": "node"` is implemented (`D-093`, section 42); ordered module filename suffixes are implemented in both profiles (`D-094`, section 43); the assembled source model with inventory, per-module binding and render evidence, direct environment uses, bounded route closures, and a golden snapshot is implemented (`D-095`, section 44); the component graph resolves render sites through bindings, module edges, and recognized wrappers, and environment classification records where modules run and which variables and runtime APIs they use (`D-096`, sections 45 and 46); `tsconfig`/`jsconfig` inheritance from linked workspace packages is implemented (`D-098`, section 48). Module and component graphs. Route tree. Render contexts. Environment classification. Static evaluator (with experiment E3 measuring evaluation coverage). Next.js configuration, convention-export, and direct-import API facts are implemented (`D-097`, section 47), preserving unknown values and bundler evidence. Golden snapshots of the project model. M4 closed under `D-099` (IMPLEMENTATION.md section 49): custom bundler rules stay configuration evidence because V1 assesses them as `unsupported`, and indirect API value flow and component values from higher-order components and factories stay explicit unknown evidence. | M2, M3 | | **M5** Assessment and analysis report (implemented) | `frameshift analyze`: **first user value** | Feature catalog for the V1 matrix, implemented as embedded data with the detection of feature occurrences and their fixed statuses (`D-099`, IMPLEMENTATION.md section 50). Read-only assessment predicates, counting ownership and accounting, transitive route readiness with witness paths, a versioned knowledge base, corpus-validated `weights-v1`, exact integer metrics, `frameshift.analysis` v1 with bounded stored parsing, text/JSON/Markdown projections, `analyze`, `report`, `explain`, assessment `rules`, and fact-based verdicts are implemented under `D-100` (IMPLEMENTATION.md section 51). Unknown preconditions remain visible; no transformation coverage is claimed. | M4 | | **M6** Planning (implemented) | `frameshift plan` | Configuration file `frameshift.jsonc` with its JSON Schema. Output path mapping for the conventional Astro layout, with collision detection. Rule registry with exclusivity checks. Operation generation. Plan document, hashing, and binding. Manual tasks. `migrate --dry-run` file listing. Migrate v0 (`D-101`) implements the output path mapping with collision detection, operations, manual tasks, and the dry-run listing as a pure function of the analysis, first (IMPLEMENTATION.md section 52). The plan document, its hash and binding, and `frameshift plan` are implemented (`D-102`, section 53), as are the configuration file (`D-103`, section 54) and the rule registry with its exclusivity check (`D-104`, section 55). | M5 | -| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. Migrate v0 (`D-101`) implements staging with an atomic rename and cleanup of its own staging directory, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | +| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest is implemented (`D-105`, IMPLEMENTATION.md section 56). Migrate v0 (`D-101`) implements staging with an atomic rename and cleanup of its own staging directory, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | | **M8** Dynamic routes, assets, styling | Common content sites | Dynamic routes with static parameters (including composition), catch-all routes, root not-found, special files, static GET route handlers, generated metadata routes, `next/image` (both strategies), `next/font`, `next/script`, Tailwind CSS v3 and v4, PostCSS, environment variable rewriting (`Q-003` resolved), metadata helper for non-literal metadata. | M7 | | **M9** Client components and islands | Interactive sites | Island selection, `client:load`, `client:only` for `ssr: false`, the compat strategy with package aliases and overrides for every supported package manager, boundary prop analysis and named slots, navigation helper, context scope classification, hook coverage, knowledge base entries for curated React libraries backed by fixtures. | M7 | | **M10** Verification | `frameshift verify`, including runtime | External tool runner (authorization, temporary copies, environment scrubbing, timeouts, log capture). Build level. Static output level with HTML normalization and per-route comparison (experiment E4 decides the HTML parser, `Q-005`). Runtime hydration, declared interaction, and selected accessibility checks using a user-installed browser. Decide browser tooling and the versioned check protocol here. Per-unit verification evidence. | M7. Full value after M8 and M9. | diff --git a/fixtures/migrate/fail-closed/expected/dry-run.txt b/fixtures/migrate/fail-closed/expected/dry-run.txt index 2e20d2f..425860d 100644 --- a/fixtures/migrate/fail-closed/expected/dry-run.txt +++ b/fixtures/migrate/fail-closed/expected/dry-run.txt @@ -1,5 +1,6 @@ Dry run: nothing will be written. config .frameshift/SUMMARY.md report.summary +config .frameshift/manifest.json report.manifest preserve .frameshift/unmigrated/src/app/captured/page.tsx preserve.unmigrated from src/app/captured/page.tsx preserve .frameshift/unmigrated/src/app/counter/page.tsx preserve.unmigrated from src/app/counter/page.tsx preserve .frameshift/unmigrated/src/app/hoisted/page.tsx preserve.unmigrated from src/app/hoisted/page.tsx @@ -20,5 +21,5 @@ create src/layouts/RootLayout.astro layout.chain from src/app/layout.tsx transform src/lib/greeting.ts module.relocate from src/lib/greeting.ts create src/pages/index.astro route.static-page from src/app/page.tsx review config tsconfig.json config.tsconfig -21 operations planned (2 converted to Astro, 13 preserved); 0 files written. +22 operations planned (2 converted to Astro, 13 preserved); 0 files written. Manual tasks: 17. See .frameshift/SUMMARY.md in the output directory. diff --git a/fixtures/migrate/fail-closed/expected/output/.frameshift/manifest.json b/fixtures/migrate/fail-closed/expected/output/.frameshift/manifest.json new file mode 100644 index 0000000..38fc93d --- /dev/null +++ b/fixtures/migrate/fail-closed/expected/output/.frameshift/manifest.json @@ -0,0 +1,234 @@ +{ + "kind": "frameshift.manifest", + "schemaVersion": 1, + "planHash": "@PLAN_HASH@", + "binding": { + "snapshot": "sha256:3bca7fc683e2083fd3202f239f20807d2cee5ac5497a5b5a7c1b7668de5106fe", + "engineVersion": "@ENGINE_VERSION@", + "ruleset": "nextjs-to-astro-preact/assessment-v1", + "knowledgeBases": [ + "2026.10.1" + ], + "targetProfile": "astro7-preact10", + "source": { + "framework": "nextjs", + "version": "16.2.1" + }, + "options": { + "to": "astro-preact", + "package": null + } + }, + "files": [ + { + "kind": "config", + "output": ".frameshift/SUMMARY.md", + "rule": "report.summary", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 3026, + "sha256": "sha256:53e5cb4f8b78825069177d2d84a06079c954167ddbeaf9b33bbd64885871dce1" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/captured/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/captured/page.tsx", + "review": false, + "size": 144, + "sha256": "sha256:4b8063db48ae67036f7436bcb159cd3be0314ee6a3e15f658504a8e084c3a11d" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/counter/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/counter/page.tsx", + "review": false, + "size": 158, + "sha256": "sha256:0d973ac9773a781abd2b139d52dd8e8cd31044eca4e2d4088ca3967583f621ae" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/hoisted/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/hoisted/page.tsx", + "review": false, + "size": 109, + "sha256": "sha256:415258f38bc92d2b4082f99057a62fb1ed6e3911466f3b83d338b34a50df149a" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/lazy/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/lazy/page.tsx", + "review": false, + "size": 137, + "sha256": "sha256:9ba0422ad3f6e05e9075378c26d455dcd37d33e057edb771e5753389827a412f" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/plain/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/plain/page.tsx", + "review": false, + "size": 103, + "sha256": "sha256:31851bf974b5efa695af7c77390ef5a26ed0105c6e724c7e74bc9621eeed8fc5" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/shadow/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/shadow/page.tsx", + "review": false, + "size": 133, + "sha256": "sha256:c69871967c03460b58064ea365977f39dbc37aa4399932e64f2d20482fc88387" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/app/tagged/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/app/tagged/page.tsx", + "review": false, + "size": 112, + "sha256": "sha256:f36b7eeff8e957ab98ec458bfd2fa858e1d6f7f369d487fc6113bc6fb1ce44c2" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/components/Counter.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/components/Counter.tsx", + "review": false, + "size": 224, + "sha256": "sha256:bd7272adc6efef9aace55c0214b49e4df8ee1332e88a23b6b4a6efc44832d0d8" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/components/Tag.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/components/Tag.tsx", + "review": false, + "size": 117, + "sha256": "sha256:cf23dbaf3b01a38b2bf9f77332052592f5a2098d8c468c731c5531397a7e90f5" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/lib/here.ts", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/lib/here.ts", + "review": false, + "size": 66, + "sha256": "sha256:8f09bf8a025b30b19a7b42dae4c60c70434978782ee69a1e84d799ad73512bb8" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/lib/label.ts", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/lib/label.ts", + "review": false, + "size": 133, + "sha256": "sha256:3af0dbba7d87e0ff397c21ad87c9ea6a7827cc2589c5f7169e855b47106f48db" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/types/augment.d.ts", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/types/augment.d.ts", + "review": false, + "size": 70, + "sha256": "sha256:ffb85de88427420dc36bbeb59c48fc1e5b6697f8f4c40c7402e8c3f1edd2a78d" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/src/types/referenced.d.ts", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "src/types/referenced.d.ts", + "review": false, + "size": 73, + "sha256": "sha256:48e0cbb2073af19a35396952f66f43b62835a30d7175d1c25ab5d9bf221495ec" + }, + { + "kind": "config", + "output": ".gitignore", + "rule": "config.gitignore", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 28, + "sha256": "sha256:c760594e89b7b78ec030eb86107c075f9d8177fa7987881c04ff6ea47d5d40cc" + }, + { + "kind": "config", + "output": "astro.config.mjs", + "rule": "config.astro", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 162, + "sha256": "sha256:089ceb7b4c4a8157addb0cd527f8f20f17b4b62db2afee13a59802e3a46ccd23" + }, + { + "kind": "config", + "output": "package.json", + "rule": "config.package-manifest", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 434, + "sha256": "sha256:4db0ee5ed8511a21aeae8db8be781d38b198a0be4d199f29847377a16e10856e" + }, + { + "kind": "create", + "output": "src/layouts/RootLayout.astro", + "rule": "layout.chain", + "ruleVersion": 1, + "source": "src/app/layout.tsx", + "review": false, + "size": 180, + "sha256": "sha256:e864c1a4178db1cec6b539b9e8140e78e4a5940e345dc84eebe0cbd51441b22c" + }, + { + "kind": "transform", + "output": "src/lib/greeting.ts", + "rule": "module.relocate", + "ruleVersion": 1, + "source": "src/lib/greeting.ts", + "review": false, + "size": 33, + "sha256": "sha256:8825446b55c5fb7d3963e5846e5d506cd2b8eccad4206694e41ed7bec58ae895" + }, + { + "kind": "create", + "output": "src/pages/index.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "src/app/page.tsx", + "review": true, + "size": 163, + "sha256": "sha256:f288fcff10abb3ab4db7dddb9af77ce2840fd9390ae9d7fef9b5760fc5da8f68" + }, + { + "kind": "config", + "output": "tsconfig.json", + "rule": "config.tsconfig", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 193, + "sha256": "sha256:746b4c28adb919320614a80e7d2e9d2f922ca45de0ad25bedba332aa8936ffc1" + } + ] +} diff --git a/fixtures/migrate/fail-closed/expected/plan.txt b/fixtures/migrate/fail-closed/expected/plan.txt index 9f8fed9..c12df91 100644 --- a/fixtures/migrate/fail-closed/expected/plan.txt +++ b/fixtures/migrate/fail-closed/expected/plan.txt @@ -1,4 +1,5 @@ config .frameshift/SUMMARY.md report.summary - +config .frameshift/manifest.json report.manifest - preserve .frameshift/unmigrated/src/app/captured/page.tsx preserve.unmigrated src/app/captured/page.tsx preserve .frameshift/unmigrated/src/app/counter/page.tsx preserve.unmigrated src/app/counter/page.tsx preserve .frameshift/unmigrated/src/app/hoisted/page.tsx preserve.unmigrated src/app/hoisted/page.tsx diff --git a/fixtures/migrate/static-site/expected/dry-run.txt b/fixtures/migrate/static-site/expected/dry-run.txt index 605a4e1..e1989a0 100644 --- a/fixtures/migrate/static-site/expected/dry-run.txt +++ b/fixtures/migrate/static-site/expected/dry-run.txt @@ -1,5 +1,6 @@ Dry run: nothing will be written. config .frameshift/SUMMARY.md report.summary +config .frameshift/manifest.json report.manifest preserve .frameshift/unmigrated/app/blog/[slug]/page.tsx preserve.unmigrated from app/blog/[slug]/page.tsx preserve .frameshift/unmigrated/app/contact/page.tsx preserve.unmigrated from app/contact/page.tsx preserve .frameshift/unmigrated/tests/site.test.ts preserve.unmigrated from tests/site.test.ts @@ -28,5 +29,5 @@ copy src/styles/globals.css style.relocate from app/globals.css transform src/types/api.d.ts module.relocate from types/api.d.ts transform src/types/assets.d.ts module.relocate from types/assets.d.ts config tsconfig.json config.tsconfig -29 operations planned (13 converted to Astro, 3 preserved); 0 files written. +30 operations planned (13 converted to Astro, 3 preserved); 0 files written. Manual tasks: 11. See .frameshift/SUMMARY.md in the output directory. diff --git a/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json b/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json new file mode 100644 index 0000000..8cb79ab --- /dev/null +++ b/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json @@ -0,0 +1,314 @@ +{ + "kind": "frameshift.manifest", + "schemaVersion": 1, + "planHash": "@PLAN_HASH@", + "binding": { + "snapshot": "sha256:392a22ad93d5e9a2d85f8c0859ed816dcc7134bf2562e2930a4eaba5ca08f40c", + "engineVersion": "@ENGINE_VERSION@", + "ruleset": "nextjs-to-astro-preact/assessment-v1", + "knowledgeBases": [ + "2026.10.1" + ], + "targetProfile": "astro7-preact10", + "source": { + "framework": "nextjs", + "version": "16.2.1" + }, + "options": { + "to": "astro-preact", + "package": null + } + }, + "files": [ + { + "kind": "config", + "output": ".frameshift/SUMMARY.md", + "rule": "report.summary", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 2514, + "sha256": "sha256:5b96fddcbf6a7c4b4cde7c833dc415ca301ef0e3210451c96c378260a1e797ea" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/app/blog/[slug]/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "app/blog/[slug]/page.tsx", + "review": false, + "size": 153, + "sha256": "sha256:4f7a0d15c200e21511fd4b7b17422605c87c86f83256c6fb9955c6431c23e122" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/app/contact/page.tsx", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "app/contact/page.tsx", + "review": false, + "size": 219, + "sha256": "sha256:57db3b5cac72835720f8fc1d5654634a526f7916540aca5504353b0d4bafe5b4" + }, + { + "kind": "preserve", + "output": ".frameshift/unmigrated/tests/site.test.ts", + "rule": "preserve.unmigrated", + "ruleVersion": 1, + "source": "tests/site.test.ts", + "review": false, + "size": 85, + "sha256": "sha256:d45f6e73e5125928a5554ff76b36ce55c3a0e5227e5bc05c6eba7be38a3d1514" + }, + { + "kind": "config", + "output": ".gitignore", + "rule": "config.gitignore", + "ruleVersion": 1, + "source": ".gitignore", + "review": false, + "size": 35, + "sha256": "sha256:6ed824521a9f00e77252d571b8af3d5d86a5993a623f9b32adbf47c0271f0674" + }, + { + "kind": "config", + "output": "astro.config.mjs", + "rule": "config.astro", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 162, + "sha256": "sha256:089ceb7b4c4a8157addb0cd527f8f20f17b4b62db2afee13a59802e3a46ccd23" + }, + { + "kind": "config", + "output": "package.json", + "rule": "config.package-manifest", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 419, + "sha256": "sha256:e73620876a99be8cb3a0201fdc1c0ca08d4e4e9a9aeaf543e8694f124212e2f6" + }, + { + "kind": "copy", + "output": "public/paper.svg", + "rule": "asset.verbatim", + "ruleVersion": 1, + "source": "public/paper.svg", + "review": false, + "size": 111, + "sha256": "sha256:39329952a9a52dbadbe0f17d972b784dbf438cfa77776a992c9a4f36799b06ef" + }, + { + "kind": "copy", + "output": "public/robots.txt", + "rule": "asset.verbatim", + "ruleVersion": 1, + "source": "public/robots.txt", + "review": false, + "size": 23, + "sha256": "sha256:16ceb5ee3e0dc13aa9adf31a3ebbe45a1d965b8c2b9f72eaf84e5911e140ed95" + }, + { + "kind": "transform", + "output": "src/components/Badge.tsx", + "rule": "component.preact-static", + "ruleVersion": 1, + "source": "components/Badge.tsx", + "review": false, + "size": 142, + "sha256": "sha256:eaba3e84ff925cc34ba379b7996516a5c7ad4309767d6f3561eecb77a76ed356" + }, + { + "kind": "create", + "output": "src/components/Card.astro", + "rule": "component.astro", + "ruleVersion": 1, + "source": "components/Card.tsx", + "review": true, + "size": 140, + "sha256": "sha256:9decbb8f327c8f1fe90a3982f45a463b0a27125541cb6eaf2608eda4673c2cd3" + }, + { + "kind": "create", + "output": "src/components/SiteHeader.astro", + "rule": "component.astro", + "ruleVersion": 1, + "source": "components/SiteHeader.tsx", + "review": true, + "size": 104, + "sha256": "sha256:f64f0e1e27784d5826cb71c1fddec1844006e280bc5de1bb97a35312758cec18" + }, + { + "kind": "create", + "output": "src/components/Stamp.astro", + "rule": "component.astro", + "ruleVersion": 1, + "source": "components/Stamp.tsx", + "review": true, + "size": 114, + "sha256": "sha256:83adbb062ab8c3873977f198155fb311b4a43fe2b0374f10e85376b755bc462b" + }, + { + "kind": "create", + "output": "src/layouts/DocsLayout.astro", + "rule": "layout.chain", + "ruleVersion": 1, + "source": "app/docs/layout.tsx", + "review": false, + "size": 328, + "sha256": "sha256:7561d5ec79ddfa5ef03f361a58291add6d62e81714ceb213d985ae7b766de176" + }, + { + "kind": "create", + "output": "src/layouts/RootLayout.astro", + "rule": "layout.chain", + "ruleVersion": 1, + "source": "app/layout.tsx", + "review": false, + "size": 520, + "sha256": "sha256:5aa9900c71fc19f087fc04cd7151fa6b7e44f6a97503160eb2e632e01587c5bd" + }, + { + "kind": "transform", + "output": "src/lib/site.ts", + "rule": "module.relocate", + "ruleVersion": 1, + "source": "lib/site.ts", + "review": false, + "size": 92, + "sha256": "sha256:d87adc6e5ecd4106df78c616f858e71d50cad5785738c3c0119b1b6d0b0a4663" + }, + { + "kind": "transform", + "output": "src/lib/types.ts", + "rule": "module.relocate", + "ruleVersion": 1, + "source": "lib/types.ts", + "review": false, + "size": 31, + "sha256": "sha256:899485279dcd413a22e30fe4def6fb759847f7c79bc484d627ad611f9fdc53e8" + }, + { + "kind": "create", + "output": "src/pages/about.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/about/page.tsx", + "review": false, + "size": 302, + "sha256": "sha256:68aaa7eae4225a398a79598f817a8f12de8586cb048de98fec567e17cf468bfb" + }, + { + "kind": "create", + "output": "src/pages/arrow.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/arrow/page.tsx", + "review": false, + "size": 305, + "sha256": "sha256:af69d25b981c0403f0f5987e8c6db6e10a72dadcd91c6325295d8bfde909fa50" + }, + { + "kind": "create", + "output": "src/pages/changelog.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/changelog/page.tsx", + "review": true, + "size": 514, + "sha256": "sha256:04d71bdf100b64f2477d7a6638166793c2f55cf0c775fdf4c1a8b04f67a5092b" + }, + { + "kind": "create", + "output": "src/pages/docs.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/docs/page.tsx", + "review": false, + "size": 203, + "sha256": "sha256:8389c5093d266010dd848a5a26a764a088b4d44a8e3b6f80f6f7ba4a897ee727" + }, + { + "kind": "create", + "output": "src/pages/docs/intro.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/docs/intro/page.tsx", + "review": false, + "size": 205, + "sha256": "sha256:2d2aeb141948cba3ecee6863b48bc382d5624120ad9b44b3de430f16502d50b9" + }, + { + "kind": "create", + "output": "src/pages/index.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/page.tsx", + "review": true, + "size": 480, + "sha256": "sha256:65a1b1d2f86e7eea288e94c981790ed1f63c47ac6c930d4b8b1a8286e6abc497" + }, + { + "kind": "create", + "output": "src/pages/pricing.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/(marketing)/pricing/page.tsx", + "review": false, + "size": 221, + "sha256": "sha256:eb0be37f6c3c378e30f6c55769076cab8016b8c8237a2bcadc8fd1163e6404a3" + }, + { + "kind": "create", + "output": "src/pages/statements.astro", + "rule": "route.static-page", + "ruleVersion": 1, + "source": "app/statements/page.tsx", + "review": false, + "size": 367, + "sha256": "sha256:4b4f039977c24238866718114a89e38267ee054d2399cf987193d5b835ff4934" + }, + { + "kind": "copy", + "output": "src/styles/globals.css", + "rule": "style.relocate", + "ruleVersion": 1, + "source": "app/globals.css", + "review": false, + "size": 93, + "sha256": "sha256:de66b2ededf9ad03882d4798f5ca322b076ce219361137227db35c2cfb29f137" + }, + { + "kind": "transform", + "output": "src/types/api.d.ts", + "rule": "module.relocate", + "ruleVersion": 1, + "source": "types/api.d.ts", + "review": false, + "size": 89, + "sha256": "sha256:d737c8ff5b5e13ebb02a2a11d43f0db30aafd3c8f91be69319c245b397b859c6" + }, + { + "kind": "transform", + "output": "src/types/assets.d.ts", + "rule": "module.relocate", + "ruleVersion": 1, + "source": "types/assets.d.ts", + "review": false, + "size": 70, + "sha256": "sha256:729c043f5f3c42e8082bdfcd60d4a2787d6cc4af39b7c2c41fb6a8cffdbf8c5b" + }, + { + "kind": "config", + "output": "tsconfig.json", + "rule": "config.tsconfig", + "ruleVersion": 1, + "source": null, + "review": false, + "size": 193, + "sha256": "sha256:746b4c28adb919320614a80e7d2e9d2f922ca45de0ad25bedba332aa8936ffc1" + } + ] +} diff --git a/fixtures/migrate/static-site/expected/plan.txt b/fixtures/migrate/static-site/expected/plan.txt index f72c59e..cae15c2 100644 --- a/fixtures/migrate/static-site/expected/plan.txt +++ b/fixtures/migrate/static-site/expected/plan.txt @@ -1,4 +1,5 @@ config .frameshift/SUMMARY.md report.summary - +config .frameshift/manifest.json report.manifest - preserve .frameshift/unmigrated/app/blog/[slug]/page.tsx preserve.unmigrated app/blog/[slug]/page.tsx preserve .frameshift/unmigrated/app/contact/page.tsx preserve.unmigrated app/contact/page.tsx preserve .frameshift/unmigrated/tests/site.test.ts preserve.unmigrated tests/site.test.ts diff --git a/schemas/frameshift.manifest.schema.json b/schemas/frameshift.manifest.schema.json new file mode 100644 index 0000000..c8cbbe6 --- /dev/null +++ b/schemas/frameshift.manifest.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Frameshift output manifest", + "description": "The provenance record of one migration, written to .frameshift/manifest.json: the plan hash, the binding of the plan, and the origin and digest of every other output file.", + "type": "object", + "additionalProperties": false, + "required": ["kind", "schemaVersion", "planHash", "binding", "files"], + "properties": { + "kind": { "const": "frameshift.manifest" }, + "schemaVersion": { "const": 1 }, + "planHash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "binding": { "$ref": "frameshift.plan.schema.json#/properties/binding" }, + "files": { "$ref": "frameshift.plan.schema.json#/properties/operations" } + } +} From 843f849830dff6fe26cbff86135725d25837def3 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:34:25 +0100 Subject: [PATCH 03/11] feat(migrate): add --clean-staging for interrupted runs Only a real directory that holds the staging marker is removed, so the option cannot delete a directory that a person created. Co-Authored-By: Claude Opus 5.5 --- crates/frameshift-cli/src/migrate_cli.rs | 28 +++++-- .../tests/golden/help-migrate.txt | 5 +- crates/frameshift-cli/tests/migrate.rs | 22 ++++++ crates/frameshift-engine/src/migrate/mod.rs | 6 ++ crates/frameshift-output/src/lib.rs | 76 ++++++++++++++++--- docs/CLI.md | 4 +- docs/IMPLEMENTATION.md | 2 +- docs/ROADMAP.md | 2 +- 8 files changed, 123 insertions(+), 22 deletions(-) diff --git a/crates/frameshift-cli/src/migrate_cli.rs b/crates/frameshift-cli/src/migrate_cli.rs index 780d382..a1e5484 100644 --- a/crates/frameshift-cli/src/migrate_cli.rs +++ b/crates/frameshift-cli/src/migrate_cli.rs @@ -31,6 +31,7 @@ pub(crate) struct Options { output: PathBuf, selection: Selection, dry_run: bool, + clean_staging: bool, plan: Option, } @@ -51,7 +52,7 @@ enum Show { Tasks, } -pub(crate) const HELP: &str = "Usage: frameshift migrate [] [--to astro-preact] --out [--package ]\n [--config |--no-config] [--plan ] [--dry-run]\n\ +pub(crate) const HELP: &str = "Usage: frameshift migrate [] [--to astro-preact] --out [--package ]\n [--config |--no-config] [--plan ] [--dry-run] [--clean-staging]\n\ Migrate the Next.js App Router project at , by default the current directory, into a\n\ new Astro project with Preact at (migrate v0, D-101). Static pages, layouts,\n\ and components that the analysis rates auto or review are converted; everything else is\n\ @@ -60,7 +61,8 @@ copied unchanged below .frameshift/unmigrated/ and listed in .frameshift/SUMMARY staging directory next to and renamed into place when complete. The source is never\n\ modified, and no project code or external tool runs. --plan refuses to continue unless the\n\ recomputed plan document equals the given one byte for byte; the given file is compared, never\n\ -executed. --dry-run lists the planned files and writes nothing. frameshift.jsonc in \n\ +executed. --dry-run lists the planned files and writes nothing. --clean-staging first removes\n\ +the marked staging directory of an interrupted run next to . frameshift.jsonc in \n\ or the --config file can set the target and the package; flags override it. Exit status: 0 on\n\ success, 2 for invalid arguments, 3 when the source or configuration cannot be read or the plan\n\ document differs, 4 when the output location is refused or cannot be written.\n"; @@ -82,7 +84,6 @@ const OVERVIEW_OPERATIONS: usize = 8; const PLANNED: &[&str] = &[ "--strict", "--on-existing", - "--clean-staging", "--report", "--show-files", "--env-names", @@ -154,7 +155,7 @@ fn parse_command( let mut seen = BTreeSet::new(); let mut operands = Vec::new(); let (mut target, mut output, mut dry_run, mut help) = (None, None, false, false); - let (mut package, mut config, mut no_config) = (None, None, false); + let (mut package, mut config, mut no_config, mut clean_staging) = (None, None, false, false); let (mut approved, mut show, mut format) = (None, Show::Overview, Format::Text); let mut i = 0; while i < args.len() { @@ -182,16 +183,22 @@ fn parse_command( if PLANNED.contains(&key) { return Err("option is not available in migrate v0"); } - if (planning && matches!(key, "--dry-run" | "--plan")) || (!planning && key == "--show") { + if (planning && matches!(key, "--dry-run" | "--plan" | "--clean-staging")) + || (!planning && key == "--show") + { return Err("unknown option; run 'frameshift help' for usage"); } - if matches!(key, "--help" | "--dry-run" | "--no-config") { + if matches!( + key, + "--help" | "--dry-run" | "--no-config" | "--clean-staging" + ) { if inline.is_some() { return Err("flag does not accept a value"); } match key { "--help" => help = true, "--dry-run" => dry_run = true, + "--clean-staging" => clean_staging = true, _ => no_config = true, } continue; @@ -286,11 +293,15 @@ fn parse_command( })); } let output = output.ok_or("migrate requires --out ")?; + if dry_run && clean_staging { + return Err("--clean-staging writes, so it cannot be part of a dry run"); + } Ok(Request::Migrate(Options { source, output, selection, dry_run, + clean_staging, plan: approved, })) } @@ -493,6 +504,11 @@ pub(crate) fn run( Err(message) => return fail(stderr, 3, message), } } + if options.clean_staging + && let Err(error) = migrate::clean_staging(&options.output) + { + return fail(stderr, code(error.kind()), &error.to_string()); + } let destination = match migrate::check_output(source, &options.output) { Ok(destination) => destination, Err(error) => return fail(stderr, code(error.kind()), &error.to_string()), diff --git a/crates/frameshift-cli/tests/golden/help-migrate.txt b/crates/frameshift-cli/tests/golden/help-migrate.txt index 5c256b3..ae4331f 100644 --- a/crates/frameshift-cli/tests/golden/help-migrate.txt +++ b/crates/frameshift-cli/tests/golden/help-migrate.txt @@ -1,5 +1,5 @@ Usage: frameshift migrate [] [--to astro-preact] --out [--package ] - [--config |--no-config] [--plan ] [--dry-run] + [--config |--no-config] [--plan ] [--dry-run] [--clean-staging] Migrate the Next.js App Router project at , by default the current directory, into a new Astro project with Preact at (migrate v0, D-101). Static pages, layouts, and components that the analysis rates auto or review are converted; everything else is @@ -8,7 +8,8 @@ copied unchanged below .frameshift/unmigrated/ and listed in .frameshift/SUMMARY staging directory next to and renamed into place when complete. The source is never modified, and no project code or external tool runs. --plan refuses to continue unless the recomputed plan document equals the given one byte for byte; the given file is compared, never -executed. --dry-run lists the planned files and writes nothing. frameshift.jsonc in +executed. --dry-run lists the planned files and writes nothing. --clean-staging first removes +the marked staging directory of an interrupted run next to . frameshift.jsonc in or the --config file can set the target and the package; flags override it. Exit status: 0 on success, 2 for invalid arguments, 3 when the source or configuration cannot be read or the plan document differs, 4 when the output location is refused or cannot be written. diff --git a/crates/frameshift-cli/tests/migrate.rs b/crates/frameshift-cli/tests/migrate.rs index 88c71ee..e21edde 100644 --- a/crates/frameshift-cli/tests/migrate.rs +++ b/crates/frameshift-cli/tests/migrate.rs @@ -284,6 +284,28 @@ fn plan_documents_approve_exactly_the_run_they_describe() { } } +#[test] +fn clean_staging_recovers_from_an_interrupted_run() { + let root = workspace("static-site", "staging"); + let staging = root.join(".out.frameshift-staging"); + fs::create_dir(&staging).unwrap(); + fs::write(staging.join("notes.txt"), "kept").unwrap(); + let mut args = MIGRATE.to_vec(); + assert_eq!(call(&root, &args).status.code(), Some(4)); + args.push("--clean-staging"); + // Without the marker, the directory is not Frameshift's to remove. + assert_eq!(call(&root, &args).status.code(), Some(4)); + assert!(staging.join("notes.txt").exists()); + fs::write(staging.join(".frameshift-staging"), "").unwrap(); + assert_eq!(call(&root, &args).status.code(), Some(0)); + assert!(!staging.exists()); + assert!(root.join("out/.frameshift/manifest.json").exists()); + // A dry run writes nothing, so it cannot clean. + args.push("--dry-run"); + assert_eq!(call(&root, &args).status.code(), Some(2)); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn plan_text_lists_operations_files_and_tasks() { let case = "static-site"; diff --git a/crates/frameshift-engine/src/migrate/mod.rs b/crates/frameshift-engine/src/migrate/mod.rs index 7bf12e8..e73588c 100644 --- a/crates/frameshift-engine/src/migrate/mod.rs +++ b/crates/frameshift-engine/src/migrate/mod.rs @@ -227,6 +227,12 @@ pub fn check_output(source: &Path, output: &Path) -> Result Result { + Ok(frameshift_output::clean_staging(output)?) +} + /// Writes every operation of `plan` into the checked destination, through /// a staging directory and an atomic rename. pub fn execute(plan: &Plan, destination: &Destination) -> Result<(), MigrateError> { diff --git a/crates/frameshift-output/src/lib.rs b/crates/frameshift-output/src/lib.rs index 7c008d8..21c75e7 100644 --- a/crates/frameshift-output/src/lib.rs +++ b/crates/frameshift-output/src/lib.rs @@ -89,6 +89,22 @@ pub fn prepare(source: &Path, output: &Path) -> Result if fs::symlink_metadata(output).is_ok() { return Err(OutputError::refused("the output location already exists")); } + let (output, staging) = locations(output)?; + if output.starts_with(&source) || source.starts_with(&output) { + return Err(OutputError::refused( + "the output must not be inside the source or contain it", + )); + } + if fs::symlink_metadata(&staging).is_ok() { + return Err(OutputError::refused( + "a staging directory from an interrupted run exists next to the output", + )); + } + Ok(Destination { output, staging }) +} + +// The resolved output path and the staging directory next to it. +fn locations(output: &Path) -> Result<(PathBuf, PathBuf), OutputError> { let name = match output.components().next_back() { Some(Component::Normal(name)) => name.to_owned(), Some( @@ -111,22 +127,33 @@ pub fn prepare(source: &Path, output: &Path) -> Result "the output's parent is not a directory", )); } - let output = parent.join(&name); - if output.starts_with(&source) || source.starts_with(&output) { - return Err(OutputError::refused( - "the output must not be inside the source or contain it", - )); - } let mut staging_name = OsString::from("."); staging_name.push(&name); staging_name.push(".frameshift-staging"); - let staging = parent.join(staging_name); - if fs::symlink_metadata(&staging).is_ok() { + Ok((parent.join(&name), parent.join(staging_name))) +} + +/// Removes the staging directory that an interrupted run left next to +/// `output`, and returns whether there was one. +/// +/// Only a real directory that holds the staging marker is removed. Anything +/// else at that name is refused, so this cannot delete a directory that a +/// person created. +pub fn clean_staging(output: &Path) -> Result { + let (_, staging) = locations(output)?; + let Ok(metadata) = fs::symlink_metadata(&staging) else { + return Ok(false); + }; + let marked = fs::symlink_metadata(staging.join(STAGING_MARKER)) + .is_ok_and(|marker| marker.file_type().is_file()); + if !metadata.file_type().is_dir() || !marked { return Err(OutputError::refused( - "a staging directory from an interrupted run exists next to the output", + "the entry at the staging location is not a marked staging directory", )); } - Ok(Destination { output, staging }) + fs::remove_dir_all(&staging) + .map_err(|error| OutputError::io("removing the staging directory failed", &error))?; + Ok(true) } /// Writes `files` into a new staging directory and renames it to the output. @@ -270,6 +297,35 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn cleaning_removes_only_a_marked_staging_directory() { + let root = temporary("clean"); + let out = root.join("out"); + let staging = root.join(".out.frameshift-staging"); + assert_eq!(clean_staging(&out), Ok(false)); + // An unmarked directory and a file at that name are never removed. + fs::create_dir(&staging).unwrap(); + fs::write(staging.join("notes.txt"), "kept").unwrap(); + assert_eq!( + clean_staging(&out).unwrap_err().kind(), + OutputErrorKind::Refused + ); + assert!(staging.join("notes.txt").exists()); + fs::remove_dir_all(&staging).unwrap(); + fs::write(&staging, "kept").unwrap(); + assert!(clean_staging(&out).is_err()); + fs::remove_file(&staging).unwrap(); + // A marked directory goes, and the location is usable again. + fs::create_dir(&staging).unwrap(); + fs::write(staging.join(STAGING_MARKER), "").unwrap(); + fs::write(staging.join("partial.txt"), "gone").unwrap(); + assert!(prepare(&root.join("source"), &out).is_err()); + assert_eq!(clean_staging(&out), Ok(true)); + assert!(!staging.exists()); + assert!(prepare(&root.join("source"), &out).is_ok()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn a_failed_write_removes_its_staging_directory() { let root = temporary("fail"); diff --git a/docs/CLI.md b/docs/CLI.md index af9cf4f..b440b93 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -175,9 +175,9 @@ frameshift migrate [] --to --out | `--env-names astro\|keep` | Naming of public environment variables (`D-021`). Default `astro` renames `NEXT_PUBLIC_X` to `PUBLIC_X`. `keep` preserves the original names. Also accepted by `plan`. | | `--config `, `--no-config` | Use a specific configuration file, or none (section 5.1, implemented, `D-103`). Also accepted by `analyze` and `plan`. | | `--on-existing fail\|replace-managed` | `fail` (default) refuses an existing output. `replace-managed` replaces an existing output only if its manifest marks it as Frameshift output and no file in it was modified since generation. The previous output is moved aside, never deleted. | -| `--clean-staging` | Remove a staging directory left by an interrupted run. Only directories carrying Frameshift's staging marker are removed. | +| `--clean-staging` | Remove a staging directory left by an interrupted run (implemented). Only real directories carrying Frameshift's staging marker are removed; anything else at that name exits with 4. It cannot be part of a `--dry-run`. | -**Implemented: migrate v0 (`D-101`).** `frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run]` exists with text output only. `--out` must name a directory that does not exist, whose parent exists, and that is neither inside the source nor contains it. The command converts static pages, layouts, and components that the analysis rates `auto` or `review` with the `astro` or `preact-static` strategy, copies everything else unchanged below `.frameshift/unmigrated/`, and writes `.frameshift/SUMMARY.md` with the manual tasks. `--dry-run` prints every planned file with its operation kind, rule, and source, and writes nothing. A real run writes through a staging directory `..frameshift-staging` next to the output and prints counts; an existing staging directory refuses the run. `--plan`, `--strict`, `--on-existing`, `--clean-staging`, `--report`, `--show-files`, `--env-names`, `--config`, `--no-config`, and `--format json` remain planned and are usage errors. Exit statuses are 0, 2, 3, 4, 6, and 70 as in section 7; preserved files are reported results and do not produce 1 or 5. Structural verification, the manifest, and `report.json` are not implemented yet. Exact contracts are in section 52 of [IMPLEMENTATION.md](IMPLEMENTATION.md). +**Implemented: migrate v0 (`D-101`).** `frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run] [--clean-staging]` exists with text output only. `--out` must name a directory that does not exist, whose parent exists, and that is neither inside the source nor contains it. The command converts static pages, layouts, and components that the analysis rates `auto` or `review` with the `astro` or `preact-static` strategy, copies everything else unchanged below `.frameshift/unmigrated/`, and writes `.frameshift/SUMMARY.md` with the manual tasks. `--dry-run` prints every planned file with its operation kind, rule, and source, and writes nothing. A real run writes through a staging directory `..frameshift-staging` next to the output and prints counts; an existing staging directory refuses the run. `--plan`, `--strict`, `--on-existing`, `--clean-staging`, `--report`, `--show-files`, `--env-names`, `--config`, `--no-config`, and `--format json` remain planned and are usage errors. Exit statuses are 0, 2, 3, 4, 6, and 70 as in section 7; preserved files are reported results and do not produce 1 or 5. Structural verification, the manifest, and `report.json` are not implemented yet. Exact contracts are in section 52 of [IMPLEMENTATION.md](IMPLEMENTATION.md). Structural verification always runs as part of `migrate`, because its checks are rule postconditions. A sample is in section 11.4 of [MIGRATION_REPORTING.md](MIGRATION_REPORTING.md). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 60c2549..4f8fe32 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1945,7 +1945,7 @@ Output paths are validated as `OutputPath` values and compared without ASCII cas **Scaffold** (`migrate/scaffold.rs`). `package.json` keeps the source name and the other `dependencies`, `devDependencies`, `optionalDependencies`, and `peerDependencies`, sorted per group, removes `next`, `react`, `react-dom`, `eslint-config-next`, `@types/react`, `@types/react-dom`, `server-only`, `client-only`, and `@next/*`, and pins `astro` 7.3.8, `@astrojs/preact` 6.0.6, `preact` 10.29.8, and `react` and `react-dom` as `npm:@preact/compat@18.3.2`, the profile that E2 built. `astro.config.mjs` enables `preact({ compat: true })`; `tsconfig.json` extends `astro/tsconfigs/strict` with Preact's JSX settings; `.gitignore` keeps the source entries and adds `dist/` and `.astro/`. `.frameshift/SUMMARY.md` lists results and tasks, with every source path in an escaped Markdown code span. Generated text uses the dominant line ending of the converted sources. A manifest is written (section 56); no report file is written yet. -**Execution** (`crates/frameshift-output`, the only product crate that writes files). `prepare` canonicalizes the source and the output's parent, refuses an existing output, a missing parent, an output inside the source or containing it, and an existing staging directory `..frameshift-staging` next to the output. `write` creates that staging directory with a `.frameshift-staging` marker, creates every file with `create_new` and `sync_all`, removes the marker, checks that the output is still absent, and renames the staging directory into place. On Windows, an access-denied rename is retried up to 20 times with growing delays, because scanners can briefly hold the new files open; other errors fail at once. On any failure it removes only the staging directory it created. Errors never contain paths. +**Execution** (`crates/frameshift-output`, the only product crate that writes files). `prepare` canonicalizes the source and the output's parent, refuses an existing output, a missing parent, an output inside the source or containing it, and an existing staging directory `..frameshift-staging` next to the output. `write` creates that staging directory with a `.frameshift-staging` marker, creates every file with `create_new` and `sync_all`, removes the marker, checks that the output is still absent, and renames the staging directory into place. On Windows, an access-denied rename is retried up to 20 times with growing delays, because scanners can briefly hold the new files open; other errors fail at once. On any failure it removes only the staging directory it created. `clean_staging`, behind `migrate --clean-staging`, removes the staging directory of an interrupted run only when it is a real directory with the marker. Errors never contain paths. **CLI.** `frameshift migrate [] --to astro-preact --out [--package ] [--dry-run]` parses arguments in `crates/frameshift-cli/src/migrate_cli.rs`, calls `plan`, `check_output`, and, without `--dry-run`, `execute`. The dry run prints every operation with its kind, escaped output path, rule, and source; a real run prints counts. Exit statuses: 0, 2 for usage errors (including every planned option), 3 for analysis input and limit failures, 4 for refused or failed output, 6 for stream failures, and 70 for internal errors. Preserved files do not change the status. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ebe189e..e099243 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Each milestone ends with a capability that is demonstrable and tested. | **M4** Semantic analysis and project model (complete) | Facts about any App Router project | Snapshot-only local module resolution, inherited `tsconfig`/`jsconfig` paths, and a deterministic import graph are implemented (`D-091`, IMPLEMENTATION.md section 37). Package `imports`, self-references, and directory entry points with per-edge resolution modes follow (`D-092`, section 38). Scopes, bindings, references, imports, and exports of each file follow (`D-092`, section 39), as do the App Router route tree, convention exports, and render contexts (section 40), and the static evaluator with experiment E3 (section 41). A separate Node10 resolution profile for `"moduleResolution": "node"` is implemented (`D-093`, section 42); ordered module filename suffixes are implemented in both profiles (`D-094`, section 43); the assembled source model with inventory, per-module binding and render evidence, direct environment uses, bounded route closures, and a golden snapshot is implemented (`D-095`, section 44); the component graph resolves render sites through bindings, module edges, and recognized wrappers, and environment classification records where modules run and which variables and runtime APIs they use (`D-096`, sections 45 and 46); `tsconfig`/`jsconfig` inheritance from linked workspace packages is implemented (`D-098`, section 48). Module and component graphs. Route tree. Render contexts. Environment classification. Static evaluator (with experiment E3 measuring evaluation coverage). Next.js configuration, convention-export, and direct-import API facts are implemented (`D-097`, section 47), preserving unknown values and bundler evidence. Golden snapshots of the project model. M4 closed under `D-099` (IMPLEMENTATION.md section 49): custom bundler rules stay configuration evidence because V1 assesses them as `unsupported`, and indirect API value flow and component values from higher-order components and factories stay explicit unknown evidence. | M2, M3 | | **M5** Assessment and analysis report (implemented) | `frameshift analyze`: **first user value** | Feature catalog for the V1 matrix, implemented as embedded data with the detection of feature occurrences and their fixed statuses (`D-099`, IMPLEMENTATION.md section 50). Read-only assessment predicates, counting ownership and accounting, transitive route readiness with witness paths, a versioned knowledge base, corpus-validated `weights-v1`, exact integer metrics, `frameshift.analysis` v1 with bounded stored parsing, text/JSON/Markdown projections, `analyze`, `report`, `explain`, assessment `rules`, and fact-based verdicts are implemented under `D-100` (IMPLEMENTATION.md section 51). Unknown preconditions remain visible; no transformation coverage is claimed. | M4 | | **M6** Planning (implemented) | `frameshift plan` | Configuration file `frameshift.jsonc` with its JSON Schema. Output path mapping for the conventional Astro layout, with collision detection. Rule registry with exclusivity checks. Operation generation. Plan document, hashing, and binding. Manual tasks. `migrate --dry-run` file listing. Migrate v0 (`D-101`) implements the output path mapping with collision detection, operations, manual tasks, and the dry-run listing as a pure function of the analysis, first (IMPLEMENTATION.md section 52). The plan document, its hash and binding, and `frameshift plan` are implemented (`D-102`, section 53), as are the configuration file (`D-103`, section 54) and the rule registry with its exclusivity check (`D-104`, section 55). | M5 | -| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest is implemented (`D-105`, IMPLEMENTATION.md section 56). Migrate v0 (`D-101`) implements staging with an atomic rename and cleanup of its own staging directory, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | +| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest is implemented (`D-105`, IMPLEMENTATION.md section 56). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | | **M8** Dynamic routes, assets, styling | Common content sites | Dynamic routes with static parameters (including composition), catch-all routes, root not-found, special files, static GET route handlers, generated metadata routes, `next/image` (both strategies), `next/font`, `next/script`, Tailwind CSS v3 and v4, PostCSS, environment variable rewriting (`Q-003` resolved), metadata helper for non-literal metadata. | M7 | | **M9** Client components and islands | Interactive sites | Island selection, `client:load`, `client:only` for `ssr: false`, the compat strategy with package aliases and overrides for every supported package manager, boundary prop analysis and named slots, navigation helper, context scope classification, hook coverage, knowledge base entries for curated React libraries backed by fixtures. | M7 | | **M10** Verification | `frameshift verify`, including runtime | External tool runner (authorization, temporary copies, environment scrubbing, timeouts, log capture). Build level. Static output level with HTML normalization and per-route comparison (experiment E4 decides the HTML parser, `Q-005`). Runtime hydration, declared interaction, and selected accessibility checks using a user-installed browser. Decide browser tooling and the versioned check protocol here. Per-unit verification evidence. | M7. Full value after M8 and M9. | From f9ac2462d4aa117c48089a371f60e8ffb18bea15 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:38:30 +0100 Subject: [PATCH 04/11] docs: record the migrate check on the real-world corpus Co-Authored-By: Claude Opus 5.5 --- docs/IMPLEMENTATION.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 4f8fe32..4a4e945 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1989,3 +1989,5 @@ Tests check that the registry is sorted and unique, that every identifier passes ## 56. Output manifest (`D-105`) `plan_document::print` also renders the `frameshift.manifest` document, and `Planner::finish` inserts it as the operation `.frameshift/manifest.json` with the rule `report.manifest`, keeping operations sorted by output path. The plan document and hash cover every other operation; the manifest names that hash, so it cannot be part of it. Goldens replace the engine version and the plan hash with placeholders. The manifest lists no timestamps or absolute paths. `--on-existing replace-managed` and `verify`, which will read it, remain planned. + +**Real-world check (2026-11-01).** On the 211 App Router package directories of the approved pinned D-043 corpus, read-only and outside the repository, `plan` exited with 0 for every package. Two plans of one package were byte-identical, a `migrate --plan` run succeeded for each, every written file (12,954 in total) matched its manifest digest, and the sources sampled before and after each run were unchanged. No project code ran. The plans held 79 `create`, 227 `transform`, 3,658 `copy`, 1,055 `config`, and 7,724 `preserve` operations. Of the preserved files, 5,408 are rated `unknown`, 1,103 are client components, and 418 are rated `unsupported`. Across the corpus, `FS3001` (`source.unknown`) occurs on 4,100 files and `FS3302` (unclassified third-party packages) on 2,695, and one file can carry both. Two sources dominate the unknown occurrences, and neither is a resolver defect: this check started each package directory as its own root, so a member whose `tsconfig.json` extends a workspace package loses that configuration (`Unresolved(Configuration)`, 6,071 of 6,885 in one member, gone with the workspace root and `--package`); and one project imports through `@/*` into a directory that its checkout lacks (`Unresolved(InvalidSpecifier)`, 4,612 of 4,693). The resolver reports the second case as an invalid specifier because `@` alone is no package scope, although the path mapping's missing target is the real cause. The corpus is deliberately broad and includes applications that are poor candidates for static migration, so these numbers measure robustness and fail-closed behavior, not typical conversion yield. From 5bfe26c67e89ef5b721b5345c704696e117c8a21 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:44:10 +0100 Subject: [PATCH 05/11] feat(migrate): replace managed output with --on-existing An existing output is replaced only if its manifest records it exactly, so no manual edit can be lost. The old tree moves next to the output and is never deleted, and it moves back if the commit fails (D-106). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- crates/frameshift-cli/src/migrate_cli.rs | 38 +++- .../tests/golden/help-migrate.txt | 5 +- crates/frameshift-cli/tests/migrate.rs | 42 ++++ .../frameshift-engine/src/migrate/managed.rs | 98 +++++++++ crates/frameshift-engine/src/migrate/mod.rs | 18 ++ crates/frameshift-output/src/lib.rs | 204 +++++++++++++++++- docs/CLI.md | 4 +- docs/DECISIONS.md | 9 + docs/IMPLEMENTATION.md | 6 +- docs/ROADMAP.md | 2 +- docs/SECURITY.md | 2 +- 12 files changed, 401 insertions(+), 29 deletions(-) create mode 100644 crates/frameshift-engine/src/migrate/managed.rs diff --git a/AGENTS.md b/AGENTS.md index 2382efa..4124909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ These instructions are binding for every agent working on Frameshift, whatever t - **M5 analysis (`D-100`).** `frameshift_engine::analysis` owns read-only assessment, counting/file accounting, transitive readiness, knowledge, exact metrics, and `frameshift.analysis` v1. Reuse `project::build_with`; no identifier-spelling inference or source execution. Unknown preconditions stay unknown, and assessment predicates never count as transformation coverage. Stored reports are bounded, validated data and never authorize reading their paths or executing operations. The CLI exposes `analyze`, stored `report`, `explain`, and assessment `rules`; explicit analysis report outputs are new files outside the source. Golden updates use `cargo test -p frameshift-engine --offline --locked --test analysis -- --ignored update_analysis_golden`. Corpus and reference-fixture commands, limits, current inference boundaries, and validated weights are in IMPLEMENTATION.md section 51. -- **Migrate v0 (`D-101`).** `frameshift_engine::migrate` plans only from `analysis::analyze_parts` (the analysis plus its snapshot, project model, and manifest); never read the source again or write outside `frameshift-output`. A conversion that moves bindings into the frontmatter scope must check them against that scope, and a relocation must refuse what the module graph has no edge for. A stored plan document is only compared with the recomputed one, never parsed or interpreted (`D-102`). Every operation names a rule in `migrate/rules.rs` of its own kind, and one source file reaches one rule; raise a rule's version whenever its output can change (`D-104`). The manifest depends on the plan hash, so it stays outside the plan document (`D-105`). Convert only units with status `auto` or `review` and strategy `astro` or `preact-static`; preserve everything else below `.frameshift/unmigrated/` with a manual task, and keep the preservation fixed point so the output never imports a file that was not written. Every rule refusal is a preserved file with a reason, never a guessed conversion. Fixtures are in `fixtures/migrate/` (IMPLEMENTATION.md section 52). +- **Migrate v0 (`D-101`).** `frameshift_engine::migrate` plans only from `analysis::analyze_parts` (the analysis plus its snapshot, project model, and manifest); never read the source again or write outside `frameshift-output`. A conversion that moves bindings into the frontmatter scope must check them against that scope, and a relocation must refuse what the module graph has no edge for. A stored plan document is only compared with the recomputed one, never parsed or interpreted (`D-102`). Every operation names a rule in `migrate/rules.rs` of its own kind, and one source file reaches one rule; raise a rule's version whenever its output can change (`D-104`). The manifest depends on the plan hash, so it stays outside the plan document (`D-105`). Replacing an output requires an exact manifest match and moves the old tree aside, never deleting it (`D-106`). Convert only units with status `auto` or `review` and strategy `astro` or `preact-static`; preserve everything else below `.frameshift/unmigrated/` with a manual task, and keep the preservation fixed point so the output never imports a file that was not written. Every rule refusal is a preserved file with a reason, never a guessed conversion. Fixtures are in `fixtures/migrate/` (IMPLEMENTATION.md section 52). - **Component graph (`D-096`).** `frameshift_engine::project::Project::components` resolves render sites through bindings and the module graph's own edges (IMPLEMENTATION.md section 45). Recognize wrappers (`memo`, `forwardRef`, `lazy`, `next/dynamic`) only through binding provenance of their imports, confirmed by the import edge's package resolution, never by callee spelling; every other call is a `Call` reason. Follow `export *` with ECMAScript's rules and keep several or unknowable providers `AmbiguousStar`. Linking charges `modules.max-work` and returns no partial graph. Its golden changes only through `cargo test -p frameshift-engine --offline --locked --test components -- --ignored update_components_golden`. diff --git a/crates/frameshift-cli/src/migrate_cli.rs b/crates/frameshift-cli/src/migrate_cli.rs index a1e5484..652d1e3 100644 --- a/crates/frameshift-cli/src/migrate_cli.rs +++ b/crates/frameshift-cli/src/migrate_cli.rs @@ -32,6 +32,7 @@ pub(crate) struct Options { selection: Selection, dry_run: bool, clean_staging: bool, + replace_managed: bool, plan: Option, } @@ -52,7 +53,7 @@ enum Show { Tasks, } -pub(crate) const HELP: &str = "Usage: frameshift migrate [] [--to astro-preact] --out [--package ]\n [--config |--no-config] [--plan ] [--dry-run] [--clean-staging]\n\ +pub(crate) const HELP: &str = "Usage: frameshift migrate [] [--to astro-preact] --out [--package ]\n [--config |--no-config] [--plan ] [--dry-run] [--clean-staging]\n [--on-existing fail|replace-managed]\n\ Migrate the Next.js App Router project at , by default the current directory, into a\n\ new Astro project with Preact at (migrate v0, D-101). Static pages, layouts,\n\ and components that the analysis rates auto or review are converted; everything else is\n\ @@ -62,7 +63,9 @@ staging directory next to and renamed into place when complete. The source modified, and no project code or external tool runs. --plan refuses to continue unless the\n\ recomputed plan document equals the given one byte for byte; the given file is compared, never\n\ executed. --dry-run lists the planned files and writes nothing. --clean-staging first removes\n\ -the marked staging directory of an interrupted run next to . frameshift.jsonc in \n\ +the marked staging directory of an interrupted run next to . --on-existing replace-managed\n\ +replaces an existing only if its manifest records it exactly, and moves the old tree next\n\ +to it instead of deleting it. frameshift.jsonc in \n\ or the --config file can set the target and the package; flags override it. Exit status: 0 on\n\ success, 2 for invalid arguments, 3 when the source or configuration cannot be read or the plan\n\ document differs, 4 when the output location is refused or cannot be written.\n"; @@ -81,13 +84,7 @@ configuration cannot be read, 4 when the plan document cannot be written.\n"; const OVERVIEW_OPERATIONS: usize = 8; // Options that CLI.md specifies for later milestones. -const PLANNED: &[&str] = &[ - "--strict", - "--on-existing", - "--report", - "--show-files", - "--env-names", -]; +const PLANNED: &[&str] = &["--strict", "--report", "--show-files", "--env-names"]; const VALUE_OPTIONS: &[&str] = &[ "--to", @@ -97,6 +94,7 @@ const VALUE_OPTIONS: &[&str] = &[ "--plan", "--show", "--config", + "--on-existing", ]; fn command(args: &[OsString]) -> Option<(usize, &str)> { @@ -157,6 +155,7 @@ fn parse_command( let (mut target, mut output, mut dry_run, mut help) = (None, None, false, false); let (mut package, mut config, mut no_config, mut clean_staging) = (None, None, false, false); let (mut approved, mut show, mut format) = (None, Show::Overview, Format::Text); + let mut replace_managed = false; let mut i = 0; while i < args.len() { if i == command { @@ -183,7 +182,11 @@ fn parse_command( if PLANNED.contains(&key) { return Err("option is not available in migrate v0"); } - if (planning && matches!(key, "--dry-run" | "--plan" | "--clean-staging")) + if (planning + && matches!( + key, + "--dry-run" | "--plan" | "--clean-staging" | "--on-existing" + )) || (!planning && key == "--show") { return Err("unknown option; run 'frameshift help' for usage"); @@ -229,6 +232,13 @@ fn parse_command( let value = value.to_str().ok_or("option values must be valid UTF-8")?; match key { "--to" => target = Some(value.to_owned()), + "--on-existing" => { + replace_managed = match value { + "fail" => false, + "replace-managed" => true, + _ => return Err("--on-existing requires fail or replace-managed"), + }; + } "--format" if value == "text" => {} "--format" if planning && value == "json" => format = Format::Json, "--format" if planning => return Err("--format requires text or json"), @@ -302,6 +312,7 @@ fn parse_command( selection, dry_run, clean_staging, + replace_managed, plan: approved, })) } @@ -509,7 +520,12 @@ pub(crate) fn run( { return fail(stderr, code(error.kind()), &error.to_string()); } - let destination = match migrate::check_output(source, &options.output) { + let checked = if options.replace_managed { + migrate::check_replacement(source, &options.output, &Limits::default()) + } else { + migrate::check_output(source, &options.output) + }; + let destination = match checked { Ok(destination) => destination, Err(error) => return fail(stderr, code(error.kind()), &error.to_string()), }; diff --git a/crates/frameshift-cli/tests/golden/help-migrate.txt b/crates/frameshift-cli/tests/golden/help-migrate.txt index ae4331f..411fdbb 100644 --- a/crates/frameshift-cli/tests/golden/help-migrate.txt +++ b/crates/frameshift-cli/tests/golden/help-migrate.txt @@ -1,5 +1,6 @@ Usage: frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run] [--clean-staging] + [--on-existing fail|replace-managed] Migrate the Next.js App Router project at , by default the current directory, into a new Astro project with Preact at (migrate v0, D-101). Static pages, layouts, and components that the analysis rates auto or review are converted; everything else is @@ -9,7 +10,9 @@ staging directory next to and renamed into place when complete. The source modified, and no project code or external tool runs. --plan refuses to continue unless the recomputed plan document equals the given one byte for byte; the given file is compared, never executed. --dry-run lists the planned files and writes nothing. --clean-staging first removes -the marked staging directory of an interrupted run next to . frameshift.jsonc in +the marked staging directory of an interrupted run next to . --on-existing replace-managed +replaces an existing only if its manifest records it exactly, and moves the old tree next +to it instead of deleting it. frameshift.jsonc in or the --config file can set the target and the package; flags override it. Exit status: 0 on success, 2 for invalid arguments, 3 when the source or configuration cannot be read or the plan document differs, 4 when the output location is refused or cannot be written. diff --git a/crates/frameshift-cli/tests/migrate.rs b/crates/frameshift-cli/tests/migrate.rs index e21edde..102d581 100644 --- a/crates/frameshift-cli/tests/migrate.rs +++ b/crates/frameshift-cli/tests/migrate.rs @@ -284,6 +284,48 @@ fn plan_documents_approve_exactly_the_run_they_describe() { } } +#[test] +fn replace_managed_replaces_only_unmodified_output() { + let root = workspace("static-site", "replace"); + let mut args = MIGRATE.to_vec(); + args.extend(["--on-existing", "replace-managed"]); + // An absent location is simply written. + assert_eq!(call(&root, &args).status.code(), Some(0)); + let golden = read_tree(&root.join("out")); + let previous = root.join(".out.frameshift-previous"); + // Unmodified output is replaced, and the old tree moves aside. + assert_eq!(call(&root, &args).status.code(), Some(0)); + assert_eq!(read_tree(&root.join("out")), golden); + assert_eq!(read_tree(&previous), golden); + // The default still refuses, and a moved tree is never overwritten. + assert_eq!(call(&root, MIGRATE).status.code(), Some(4)); + assert_eq!(call(&root, &args).status.code(), Some(4)); + fs::remove_dir_all(&previous).unwrap(); + // An edited, added, or removed file protects the whole tree. + let edited = root.join("out/src/pages/index.astro"); + let original = fs::read(&edited).unwrap(); + fs::write(&edited, "edited").unwrap(); + assert_eq!(call(&root, &args).status.code(), Some(4)); + assert_eq!(fs::read(&edited).unwrap(), b"edited"); + fs::write(&edited, &original).unwrap(); + fs::write(root.join("out/notes.txt"), "mine").unwrap(); + assert_eq!(call(&root, &args).status.code(), Some(4)); + fs::remove_file(root.join("out/notes.txt")).unwrap(); + fs::remove_file(root.join("out/public/robots.txt")).unwrap(); + assert_eq!(call(&root, &args).status.code(), Some(4)); + assert!(!previous.exists()); + // A directory without a manifest is not Frameshift's to replace. + fs::remove_dir_all(root.join("out")).unwrap(); + fs::create_dir_all(root.join("out")).unwrap(); + fs::write(root.join("out/mine.txt"), "mine").unwrap(); + assert_eq!(call(&root, &args).status.code(), Some(4)); + assert!(root.join("out/mine.txt").exists()); + let mut invalid = MIGRATE.to_vec(); + invalid.extend(["--on-existing", "overwrite"]); + assert_eq!(call(&root, &invalid).status.code(), Some(2)); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn clean_staging_recovers_from_an_interrupted_run() { let root = workspace("static-site", "staging"); diff --git a/crates/frameshift-engine/src/migrate/managed.rs b/crates/frameshift-engine/src/migrate/managed.rs new file mode 100644 index 0000000..982c249 --- /dev/null +++ b/crates/frameshift-engine/src/migrate/managed.rs @@ -0,0 +1,98 @@ +//! Whether an existing output may be replaced (`D-106`). +//! +//! A location is managed only when its manifest names every file in the tree +//! with its exact digest, so no manual edit can be lost. The manifest is +//! untrusted data: it can only make a replacement fail or succeed, and the +//! replaced tree is moved aside, never deleted. + +use std::collections::BTreeMap; +use std::path::Path; + +use frameshift_core::{Sha256Digest, SourceText}; +use frameshift_syntax::{JsonDialect, JsonValue, parse_json}; + +use super::{MANIFEST, MANIFEST_SCHEMA_VERSION, MigrateError, MigrateErrorKind}; +use crate::limits::Limits; + +fn refused(message: &str) -> MigrateError { + MigrateError { + kind: MigrateErrorKind::Output, + message: message.to_owned(), + } +} + +// The path and digest of every file that the manifest lists. +fn listed_files(bytes: Vec, limits: &Limits) -> Result, MigrateError> { + let invalid = || refused("the existing output has no valid Frameshift manifest"); + let text = SourceText::from_bytes(bytes, limits.text).map_err(|_| invalid())?; + let document = parse_json(&text, JsonDialect::Json, limits.json).map_err(|_| invalid())?; + let member = |object: frameshift_syntax::JsonObject<'_>, key: &str| { + object.get(key).and_then(|id| document.value(id)) + }; + let Some(JsonValue::Object(root)) = document.value(document.root()) else { + return Err(invalid()); + }; + if !matches!( + member(root, "kind"), + Some(JsonValue::String("frameshift.manifest")) + ) { + return Err(invalid()); + } + let version = match member(root, "schemaVersion") { + Some(JsonValue::Number(number)) => number.as_u64(), + _ => None, + }; + if version != Some(MANIFEST_SCHEMA_VERSION) { + return Err(invalid()); + } + let Some(JsonValue::Array(files)) = member(root, "files") else { + return Err(invalid()); + }; + let mut listed = BTreeMap::new(); + for id in files.iter() { + let Some(JsonValue::Object(file)) = document.value(id) else { + return Err(invalid()); + }; + let (Some(JsonValue::String(output)), Some(JsonValue::String(digest))) = + (member(file, "output"), member(file, "sha256")) + else { + return Err(invalid()); + }; + if listed + .insert(output.to_owned(), digest.to_owned()) + .is_some() + { + return Err(invalid()); + } + } + Ok(listed) +} + +/// Checks that the tree at `output` is exactly what its manifest records. +pub(super) fn check(output: &Path, limits: &Limits) -> Result<(), MigrateError> { + let modified = || refused("the existing output was modified since it was generated"); + let mut listed = None; + let mut digests = BTreeMap::new(); + let mut hash_failure = false; + frameshift_output::visit_tree(output, limits.run, &mut |path, bytes| { + if path == MANIFEST { + listed = Some(bytes.to_vec()); + } else if let Ok(digest) = Sha256Digest::hash(bytes, limits.hash) { + digests.insert(path.to_owned(), digest.to_string()); + } else { + hash_failure = true; + } + })?; + if hash_failure { + return Err(modified()); + } + let Some(listed) = listed else { + return Err(refused( + "the existing output has no valid Frameshift manifest", + )); + }; + if listed_files(listed, limits)? != digests { + return Err(modified()); + } + Ok(()) +} diff --git a/crates/frameshift-engine/src/migrate/mod.rs b/crates/frameshift-engine/src/migrate/mod.rs index e73588c..1f3dd79 100644 --- a/crates/frameshift-engine/src/migrate/mod.rs +++ b/crates/frameshift-engine/src/migrate/mod.rs @@ -10,6 +10,7 @@ //! through `frameshift-output`, into a new directory outside the source. mod convert; +mod managed; mod plan_document; pub mod rules; mod scaffold; @@ -227,6 +228,23 @@ pub fn check_output(source: &Path, output: &Path) -> Result Result { + if std::fs::symlink_metadata(output).is_err() { + return check_output(source, output); + } + // The location is checked first, so a tree inside the source is never read. + let destination = frameshift_output::prepare_replacement(source, output)?; + managed::check(output, limits)?; + Ok(destination) +} + /// Removes the marked staging directory that an interrupted run left next to /// `output`, and returns whether there was one. pub fn clean_staging(output: &Path) -> Result { diff --git a/crates/frameshift-output/src/lib.rs b/crates/frameshift-output/src/lib.rs index 21c75e7..e55d9c7 100644 --- a/crates/frameshift-output/src/lib.rs +++ b/crates/frameshift-output/src/lib.rs @@ -12,7 +12,7 @@ use std::fs::{self, OpenOptions}; use std::io::{self, Write as _}; use std::path::{Component, Path, PathBuf}; -use frameshift_core::OutputPath; +use frameshift_core::{OutputPath, RunLimits}; /// Name of the marker file that identifies a Frameshift staging directory. pub const STAGING_MARKER: &str = ".frameshift-staging"; @@ -76,6 +76,8 @@ pub struct OutputFile { pub struct Destination { output: PathBuf, staging: PathBuf, + // Where a replaced output moves, which never exists beforehand. + previous: Option, } /// Checks that `output` can receive a new tree for the source at `source`. @@ -90,7 +92,40 @@ pub fn prepare(source: &Path, output: &Path) -> Result return Err(OutputError::refused("the output location already exists")); } let (output, staging) = locations(output)?; - if output.starts_with(&source) || source.starts_with(&output) { + checked(&source, output, staging, None) +} + +/// Checks that the existing directory at `output` can be replaced by a new +/// tree. The caller has established that the tree is safe to replace. The +/// replaced tree moves next to the output; nothing is deleted. +pub fn prepare_replacement(source: &Path, output: &Path) -> Result { + let source = fs::canonicalize(source) + .map_err(|_| OutputError::refused("the source directory cannot be resolved"))?; + let is_directory = + fs::symlink_metadata(output).is_ok_and(|metadata| metadata.file_type().is_dir()); + if !is_directory { + return Err(OutputError::refused( + "the output location is not a real directory", + )); + } + let (resolved, staging) = locations(output)?; + let mut previous = staging.clone(); + previous.set_extension("frameshift-previous"); + if fs::symlink_metadata(&previous).is_ok() { + return Err(OutputError::refused( + "a previous output that was moved aside still exists next to the output", + )); + } + checked(&source, resolved, staging, Some(previous)) +} + +fn checked( + source: &Path, + output: PathBuf, + staging: PathBuf, + previous: Option, +) -> Result { + if output.starts_with(source) || source.starts_with(&output) { return Err(OutputError::refused( "the output must not be inside the source or contain it", )); @@ -100,7 +135,11 @@ pub fn prepare(source: &Path, output: &Path) -> Result "a staging directory from an interrupted run exists next to the output", )); } - Ok(Destination { output, staging }) + Ok(Destination { + output, + staging, + previous, + }) } // The resolved output path and the staging directory next to it. @@ -179,20 +218,103 @@ pub fn write(destination: &Destination, files: &[OutputFile]) -> Result<(), Outp OutputError::io("creating the staging directory failed", &error) } })?; - let result = fill(&destination.staging, files).and_then(|()| { + let result = fill(&destination.staging, files).and_then(|()| commit(destination)); + if result.is_err() { + // Only this run's own staging directory is removed. + let _ = fs::remove_dir_all(&destination.staging); + } + result +} + +fn commit(destination: &Destination) -> Result<(), OutputError> { + let failed = |error: &io::Error| OutputError::io("moving the output into place failed", error); + let Some(previous) = &destination.previous else { if fs::symlink_metadata(&destination.output).is_ok() { return Err(OutputError::refused( "the output location appeared during the run", )); } - rename(&destination.staging, &destination.output) - .map_err(|error| OutputError::io("moving the output into place failed", &error)) - }); - if result.is_err() { - // Only this run's own staging directory is removed. - let _ = fs::remove_dir_all(&destination.staging); + return rename(&destination.staging, &destination.output).map_err(|e| failed(&e)); + }; + rename(&destination.output, previous).map_err(|e| failed(&e))?; + if let Err(error) = rename(&destination.staging, &destination.output) { + // The replaced tree goes back, so a failed run changes nothing. + let _ = rename(previous, &destination.output); + return Err(failed(&error)); } - result + Ok(()) +} + +/// Calls `visit` with every file below `root`, by slash-separated relative +/// path, in byte order. A link or any other entry that is not a regular file +/// or directory stops the walk, and so does exceeding `limits`. +pub fn visit_tree( + root: &Path, + limits: RunLimits, + visit: &mut dyn FnMut(&str, &[u8]), +) -> Result<(), OutputError> { + let mut files = 0; + let mut total = 0; + walk(root, "", 0, limits, &mut files, &mut total, visit) +} + +fn walk( + directory: &Path, + prefix: &str, + depth: usize, + limits: RunLimits, + files: &mut usize, + total: &mut u64, + visit: &mut dyn FnMut(&str, &[u8]), +) -> Result<(), OutputError> { + let unreadable = + |error: &io::Error| OutputError::io("reading the existing output failed", error); + let too_large = || OutputError::refused("the existing output exceeds the run limits"); + if depth > limits.max_directory_depth { + return Err(too_large()); + } + let mut entries = fs::read_dir(directory) + .map_err(|e| unreadable(&e))? + .collect::>>() + .map_err(|e| unreadable(&e))?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return Err(OutputError::refused( + "the existing output holds a name that is not valid UTF-8", + )); + }; + let relative = format!("{prefix}{name}"); + let kind = entry.file_type().map_err(|e| unreadable(&e))?; + if kind.is_dir() { + walk( + &entry.path(), + &format!("{relative}/"), + depth + 1, + limits, + files, + total, + visit, + )?; + } else if kind.is_file() { + *files += 1; + let bytes = fs::read(entry.path()).map_err(|e| unreadable(&e))?; + *total += bytes.len() as u64; + if *files > limits.max_files + || bytes.len() as u64 > limits.max_file_bytes + || *total > limits.max_total_bytes + { + return Err(too_large()); + } + visit(&relative, &bytes); + } else { + return Err(OutputError::refused( + "the existing output holds a link or another special entry", + )); + } + } + Ok(()) } // On Windows, a scanner or indexer may briefly hold files of the new tree @@ -297,6 +419,66 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn replacement_moves_the_old_tree_aside() { + let root = temporary("replace"); + let out = root.join("out"); + let source = root.join("source"); + write(&prepare(&source, &out).unwrap(), &[file("a.txt", b"old")]).unwrap(); + let destination = prepare_replacement(&source, &out).unwrap(); + write(&destination, &[file("a.txt", b"new")]).unwrap(); + assert_eq!(fs::read(out.join("a.txt")).unwrap(), b"new"); + let previous = root.join(".out.frameshift-previous"); + assert_eq!(fs::read(previous.join("a.txt")).unwrap(), b"old"); + // The moved tree is never overwritten by a second replacement. + assert!(prepare_replacement(&source, &out).is_err()); + fs::remove_dir_all(&previous).unwrap(); + assert!(prepare_replacement(&source, &root.join("missing")).is_err()); + assert!(prepare_replacement(&source, &source).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_failed_replacement_keeps_the_old_tree() { + let root = temporary("replace-fail"); + let out = root.join("out"); + let source = root.join("source"); + write(&prepare(&source, &out).unwrap(), &[file("a.txt", b"old")]).unwrap(); + let destination = prepare_replacement(&source, &out).unwrap(); + // Duplicate paths fail in the staging directory, before the commit. + assert!(write(&destination, &[file("a", b"1"), file("a", b"2")]).is_err()); + assert_eq!(fs::read(out.join("a.txt")).unwrap(), b"old"); + assert!(!root.join(".out.frameshift-previous").exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn visiting_a_tree_is_ordered_and_bounded() { + let root = temporary("visit"); + let tree = root.join("tree"); + fs::create_dir_all(tree.join("b")).unwrap(); + fs::write(tree.join("b/c.txt"), "c").unwrap(); + fs::write(tree.join("a.txt"), "a").unwrap(); + let mut seen = Vec::new(); + visit_tree(&tree, RunLimits::default(), &mut |path, bytes| { + seen.push((path.to_owned(), bytes.to_vec())); + }) + .unwrap(); + assert_eq!( + seen, + [ + ("a.txt".to_owned(), b"a".to_vec()), + ("b/c.txt".to_owned(), b"c".to_vec()) + ] + ); + let limits = RunLimits { + max_files: 1, + ..RunLimits::default() + }; + assert!(visit_tree(&tree, limits, &mut |_, _| {}).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn cleaning_removes_only_a_marked_staging_directory() { let root = temporary("clean"); diff --git a/docs/CLI.md b/docs/CLI.md index b440b93..6f05fb1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -174,10 +174,10 @@ frameshift migrate [] --to --out | `--strict` | Abort before commit if any unit is `unsupported`, `unknown`, or `failed`. Nothing is written, and the exit code is 1. | | `--env-names astro\|keep` | Naming of public environment variables (`D-021`). Default `astro` renames `NEXT_PUBLIC_X` to `PUBLIC_X`. `keep` preserves the original names. Also accepted by `plan`. | | `--config `, `--no-config` | Use a specific configuration file, or none (section 5.1, implemented, `D-103`). Also accepted by `analyze` and `plan`. | -| `--on-existing fail\|replace-managed` | `fail` (default) refuses an existing output. `replace-managed` replaces an existing output only if its manifest marks it as Frameshift output and no file in it was modified since generation. The previous output is moved aside, never deleted. | +| `--on-existing fail\|replace-managed` | `fail` (default) refuses an existing output. `replace-managed` (implemented, `D-106`) replaces an existing output only if its manifest marks it as Frameshift output and no file in it was added, removed, or modified since generation; otherwise it exits with 4. The previous output moves to `..frameshift-previous` next to it and is never deleted; an existing tree at that name also exits with 4. | | `--clean-staging` | Remove a staging directory left by an interrupted run (implemented). Only real directories carrying Frameshift's staging marker are removed; anything else at that name exits with 4. It cannot be part of a `--dry-run`. | -**Implemented: migrate v0 (`D-101`).** `frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run] [--clean-staging]` exists with text output only. `--out` must name a directory that does not exist, whose parent exists, and that is neither inside the source nor contains it. The command converts static pages, layouts, and components that the analysis rates `auto` or `review` with the `astro` or `preact-static` strategy, copies everything else unchanged below `.frameshift/unmigrated/`, and writes `.frameshift/SUMMARY.md` with the manual tasks. `--dry-run` prints every planned file with its operation kind, rule, and source, and writes nothing. A real run writes through a staging directory `..frameshift-staging` next to the output and prints counts; an existing staging directory refuses the run. `--plan`, `--strict`, `--on-existing`, `--clean-staging`, `--report`, `--show-files`, `--env-names`, `--config`, `--no-config`, and `--format json` remain planned and are usage errors. Exit statuses are 0, 2, 3, 4, 6, and 70 as in section 7; preserved files are reported results and do not produce 1 or 5. Structural verification, the manifest, and `report.json` are not implemented yet. Exact contracts are in section 52 of [IMPLEMENTATION.md](IMPLEMENTATION.md). +**Implemented: migrate v0 (`D-101`).** `frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run] [--clean-staging] [--on-existing fail|replace-managed]` exists with text output only. `--out` must name a directory that does not exist, whose parent exists, and that is neither inside the source nor contains it. The command converts static pages, layouts, and components that the analysis rates `auto` or `review` with the `astro` or `preact-static` strategy, copies everything else unchanged below `.frameshift/unmigrated/`, and writes `.frameshift/SUMMARY.md` with the manual tasks. `--dry-run` prints every planned file with its operation kind, rule, and source, and writes nothing. A real run writes through a staging directory `..frameshift-staging` next to the output and prints counts; an existing staging directory refuses the run. `--plan`, `--strict`, `--on-existing`, `--clean-staging`, `--report`, `--show-files`, `--env-names`, `--config`, `--no-config`, and `--format json` remain planned and are usage errors. Exit statuses are 0, 2, 3, 4, 6, and 70 as in section 7; preserved files are reported results and do not produce 1 or 5. Structural verification, the manifest, and `report.json` are not implemented yet. Exact contracts are in section 52 of [IMPLEMENTATION.md](IMPLEMENTATION.md). Structural verification always runs as part of `migrate`, because its checks are rule postconditions. A sample is in section 11.4 of [MIGRATION_REPORTING.md](MIGRATION_REPORTING.md). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 46cda3e..0a95bf2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -113,6 +113,7 @@ Statuses change through an edit to this file under the integration workflow in D | D-103 | The key set of `frameshift.jsonc` | Accepted | | D-104 | The rule registry of migrate | Accepted | | D-105 | The output manifest | Accepted | +| D-106 | Replacing managed output | Accepted | | P-001 | Go as the single implementation language, CGO disabled | Superseded by D-018 | | P-002 | Purpose-built parsers | Accepted | | P-003 | Span-based edits and targeted printers; no concrete syntax tree initially | Accepted | @@ -1218,6 +1219,14 @@ Statuses change through an edit to this file under the integration workflow in D - **Tradeoffs:** A manifest holds no timestamps or absolute paths, so it stays deterministic but cannot tell when a run happened. Source spans and output regions from the provenance model are not recorded yet. - **Revisit if:** `verify` needs per-region provenance. +### D-106: Replacing managed output + +- **Status:** Accepted (2026-11-01), implementing the overwrite policy of section 4.4 of [CLI.md](CLI.md) and section 7 of [SECURITY.md](SECURITY.md). +- **Decision:** `migrate --on-existing replace-managed` replaces an existing output only if the set of files below it equals the set that `.frameshift/manifest.json` lists plus the manifest itself, and every digest matches. A missing or invalid manifest, a link or special entry, an edited, added, or removed file, and an output inside the source all refuse the run with exit code 4. The replaced tree moves to `..frameshift-previous` next to the output and is never deleted; an existing tree at that name refuses the run, so the user decides when to remove it. If the second rename fails, the old tree moves back. +- **Rationale:** The manifest proves that a location is Frameshift's and unchanged, so no manual edit can be lost. Moving instead of deleting keeps every failure recoverable. +- **Tradeoffs:** A user must remove the moved tree before the next replacement. The manifest is not authenticated, so someone who edits both a file and its digest can pass the check; the policy protects against accidental loss, not against an attacker with write access to the output. +- **Revisit if:** Repeated regeneration makes removing the moved tree a burden. + ## Technique proposals and related accepted decisions diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 4a4e945..afedf28 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1988,6 +1988,10 @@ Tests check that the registry is sorted and unique, that every identifier passes ## 56. Output manifest (`D-105`) -`plan_document::print` also renders the `frameshift.manifest` document, and `Planner::finish` inserts it as the operation `.frameshift/manifest.json` with the rule `report.manifest`, keeping operations sorted by output path. The plan document and hash cover every other operation; the manifest names that hash, so it cannot be part of it. Goldens replace the engine version and the plan hash with placeholders. The manifest lists no timestamps or absolute paths. `--on-existing replace-managed` and `verify`, which will read it, remain planned. +`plan_document::print` also renders the `frameshift.manifest` document, and `Planner::finish` inserts it as the operation `.frameshift/manifest.json` with the rule `report.manifest`, keeping operations sorted by output path. The plan document and hash cover every other operation; the manifest names that hash, so it cannot be part of it. Goldens replace the engine version and the plan hash with placeholders. The manifest lists no timestamps or absolute paths. `--on-existing replace-managed` reads it (section 57), and `verify` remains planned. **Real-world check (2026-11-01).** On the 211 App Router package directories of the approved pinned D-043 corpus, read-only and outside the repository, `plan` exited with 0 for every package. Two plans of one package were byte-identical, a `migrate --plan` run succeeded for each, every written file (12,954 in total) matched its manifest digest, and the sources sampled before and after each run were unchanged. No project code ran. The plans held 79 `create`, 227 `transform`, 3,658 `copy`, 1,055 `config`, and 7,724 `preserve` operations. Of the preserved files, 5,408 are rated `unknown`, 1,103 are client components, and 418 are rated `unsupported`. Across the corpus, `FS3001` (`source.unknown`) occurs on 4,100 files and `FS3302` (unclassified third-party packages) on 2,695, and one file can carry both. Two sources dominate the unknown occurrences, and neither is a resolver defect: this check started each package directory as its own root, so a member whose `tsconfig.json` extends a workspace package loses that configuration (`Unresolved(Configuration)`, 6,071 of 6,885 in one member, gone with the workspace root and `--package`); and one project imports through `@/*` into a directory that its checkout lacks (`Unresolved(InvalidSpecifier)`, 4,612 of 4,693). The resolver reports the second case as an invalid specifier because `@` alone is no package scope, although the path mapping's missing target is the real cause. The corpus is deliberately broad and includes applications that are poor candidates for static migration, so these numbers measure robustness and fail-closed behavior, not typical conversion yield. + +## 57. Replacing managed output (`D-106`) + +`migrate::check_replacement` treats an absent location as new. For an existing one, `frameshift_output::prepare_replacement` first refuses anything but a real directory outside the source, then `migrate/managed.rs` walks it with `frameshift_output::visit_tree` (no links or special entries, bounded by the run limits) and compares its file set and digests with the manifest. `Destination::commit` moves the old tree to `..frameshift-previous`, moves the staging directory into place, and moves the old tree back if that fails. A failed write in the staging directory leaves the old tree untouched. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e099243..79dfadc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Each milestone ends with a capability that is demonstrable and tested. | **M4** Semantic analysis and project model (complete) | Facts about any App Router project | Snapshot-only local module resolution, inherited `tsconfig`/`jsconfig` paths, and a deterministic import graph are implemented (`D-091`, IMPLEMENTATION.md section 37). Package `imports`, self-references, and directory entry points with per-edge resolution modes follow (`D-092`, section 38). Scopes, bindings, references, imports, and exports of each file follow (`D-092`, section 39), as do the App Router route tree, convention exports, and render contexts (section 40), and the static evaluator with experiment E3 (section 41). A separate Node10 resolution profile for `"moduleResolution": "node"` is implemented (`D-093`, section 42); ordered module filename suffixes are implemented in both profiles (`D-094`, section 43); the assembled source model with inventory, per-module binding and render evidence, direct environment uses, bounded route closures, and a golden snapshot is implemented (`D-095`, section 44); the component graph resolves render sites through bindings, module edges, and recognized wrappers, and environment classification records where modules run and which variables and runtime APIs they use (`D-096`, sections 45 and 46); `tsconfig`/`jsconfig` inheritance from linked workspace packages is implemented (`D-098`, section 48). Module and component graphs. Route tree. Render contexts. Environment classification. Static evaluator (with experiment E3 measuring evaluation coverage). Next.js configuration, convention-export, and direct-import API facts are implemented (`D-097`, section 47), preserving unknown values and bundler evidence. Golden snapshots of the project model. M4 closed under `D-099` (IMPLEMENTATION.md section 49): custom bundler rules stay configuration evidence because V1 assesses them as `unsupported`, and indirect API value flow and component values from higher-order components and factories stay explicit unknown evidence. | M2, M3 | | **M5** Assessment and analysis report (implemented) | `frameshift analyze`: **first user value** | Feature catalog for the V1 matrix, implemented as embedded data with the detection of feature occurrences and their fixed statuses (`D-099`, IMPLEMENTATION.md section 50). Read-only assessment predicates, counting ownership and accounting, transitive route readiness with witness paths, a versioned knowledge base, corpus-validated `weights-v1`, exact integer metrics, `frameshift.analysis` v1 with bounded stored parsing, text/JSON/Markdown projections, `analyze`, `report`, `explain`, assessment `rules`, and fact-based verdicts are implemented under `D-100` (IMPLEMENTATION.md section 51). Unknown preconditions remain visible; no transformation coverage is claimed. | M4 | | **M6** Planning (implemented) | `frameshift plan` | Configuration file `frameshift.jsonc` with its JSON Schema. Output path mapping for the conventional Astro layout, with collision detection. Rule registry with exclusivity checks. Operation generation. Plan document, hashing, and binding. Manual tasks. `migrate --dry-run` file listing. Migrate v0 (`D-101`) implements the output path mapping with collision detection, operations, manual tasks, and the dry-run listing as a pure function of the analysis, first (IMPLEMENTATION.md section 52). The plan document, its hash and binding, and `frameshift plan` are implemented (`D-102`, section 53), as are the configuration file (`D-103`, section 54) and the rule registry with its exclusivity check (`D-104`, section 55). | M5 | -| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest is implemented (`D-105`, IMPLEMENTATION.md section 56). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | +| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest and the `replace-managed` overwrite policy are implemented (`D-105`, `D-106`, IMPLEMENTATION.md sections 56 and 57). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | | **M8** Dynamic routes, assets, styling | Common content sites | Dynamic routes with static parameters (including composition), catch-all routes, root not-found, special files, static GET route handlers, generated metadata routes, `next/image` (both strategies), `next/font`, `next/script`, Tailwind CSS v3 and v4, PostCSS, environment variable rewriting (`Q-003` resolved), metadata helper for non-literal metadata. | M7 | | **M9** Client components and islands | Interactive sites | Island selection, `client:load`, `client:only` for `ssr: false`, the compat strategy with package aliases and overrides for every supported package manager, boundary prop analysis and named slots, navigation helper, context scope classification, hook coverage, knowledge base entries for curated React libraries backed by fixtures. | M7 | | **M10** Verification | `frameshift verify`, including runtime | External tool runner (authorization, temporary copies, environment scrubbing, timeouts, log capture). Build level. Static output level with HTML normalization and per-route comparison (experiment E4 decides the HTML parser, `Q-005`). Runtime hydration, declared interaction, and selected accessibility checks using a user-installed browser. Decide browser tooling and the versioned check protocol here. Per-unit verification evidence. | M7. Full value after M8 and M9. | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0631f67..57ca848 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -146,7 +146,7 @@ There is no boundary to the network in Frameshift itself. A future optional mode - The output location must not exist or must be empty, unless `--on-existing replace-managed` applies. - The output must not be the source, inside the source, or contain the source. It must not be a filesystem root or the user's home directory. - Writes go to a staging directory next to the output, then are committed atomically by rename. -- `replace-managed` requires a valid Frameshift manifest, and every file must still match its recorded hash. Modified output is never replaced (`FS7004`). The previous output is moved aside, not deleted. +- `replace-managed` requires a valid Frameshift manifest, and every file must still match its recorded hash. Modified output is never replaced (`FS7004`). The previous output is moved aside, not deleted (implemented, `D-106`). The manifest is not authenticated, so this protects against accidental loss, not against an attacker with write access to the output. - Staging cleanup removes only directories that carry Frameshift's staging marker. - Output paths are validated: no traversal, no absolute paths, no drive letters or alternate data streams, no Windows reserved names, no control characters, and no collisions that differ only in case or Unicode normalization. From c500228ce6a5b4a12622e9b01b5b2508c722e895 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:50:26 +0100 Subject: [PATCH 06/11] feat(migrate): add --strict and --show-files --strict writes nothing and exits with 1 when a unit is rated unknown or unsupported, and --show-files lists every operation of a real run. Co-Authored-By: Claude Opus 5.5 --- crates/frameshift-cli/src/migrate_cli.rs | 45 ++++++++++++++++--- crates/frameshift-cli/tests/cli.rs | 2 +- .../tests/golden/help-migrate.txt | 8 ++-- crates/frameshift-cli/tests/migrate.rs | 20 +++++++++ crates/frameshift-engine/src/migrate/mod.rs | 11 +++++ docs/CLI.md | 6 +-- docs/IMPLEMENTATION.md | 2 +- docs/ROADMAP.md | 2 +- 8 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/frameshift-cli/src/migrate_cli.rs b/crates/frameshift-cli/src/migrate_cli.rs index 652d1e3..dd9876a 100644 --- a/crates/frameshift-cli/src/migrate_cli.rs +++ b/crates/frameshift-cli/src/migrate_cli.rs @@ -33,6 +33,8 @@ pub(crate) struct Options { dry_run: bool, clean_staging: bool, replace_managed: bool, + strict: bool, + show_files: bool, plan: Option, } @@ -53,7 +55,7 @@ enum Show { Tasks, } -pub(crate) const HELP: &str = "Usage: frameshift migrate [] [--to astro-preact] --out [--package ]\n [--config |--no-config] [--plan ] [--dry-run] [--clean-staging]\n [--on-existing fail|replace-managed]\n\ +pub(crate) const HELP: &str = "Usage: frameshift migrate [] [--to astro-preact] --out [--package ]\n [--config |--no-config] [--plan ] [--dry-run] [--show-files] [--strict]\n [--clean-staging] [--on-existing fail|replace-managed]\n\ Migrate the Next.js App Router project at , by default the current directory, into a\n\ new Astro project with Preact at (migrate v0, D-101). Static pages, layouts,\n\ and components that the analysis rates auto or review are converted; everything else is\n\ @@ -62,7 +64,9 @@ copied unchanged below .frameshift/unmigrated/ and listed in .frameshift/SUMMARY staging directory next to and renamed into place when complete. The source is never\n\ modified, and no project code or external tool runs. --plan refuses to continue unless the\n\ recomputed plan document equals the given one byte for byte; the given file is compared, never\n\ -executed. --dry-run lists the planned files and writes nothing. --clean-staging first removes\n\ +executed. --dry-run lists the planned files and writes nothing, and --show-files lists them in\n\ +a real run. --strict writes nothing and exits with 1 if any unit is unknown or unsupported.\n\ +--clean-staging first removes\n\ the marked staging directory of an interrupted run next to . --on-existing replace-managed\n\ replaces an existing only if its manifest records it exactly, and moves the old tree next\n\ to it instead of deleting it. frameshift.jsonc in \n\ @@ -84,7 +88,7 @@ configuration cannot be read, 4 when the plan document cannot be written.\n"; const OVERVIEW_OPERATIONS: usize = 8; // Options that CLI.md specifies for later milestones. -const PLANNED: &[&str] = &["--strict", "--report", "--show-files", "--env-names"]; +const PLANNED: &[&str] = &["--report", "--env-names"]; const VALUE_OPTIONS: &[&str] = &[ "--to", @@ -155,7 +159,7 @@ fn parse_command( let (mut target, mut output, mut dry_run, mut help) = (None, None, false, false); let (mut package, mut config, mut no_config, mut clean_staging) = (None, None, false, false); let (mut approved, mut show, mut format) = (None, Show::Overview, Format::Text); - let mut replace_managed = false; + let (mut replace_managed, mut strict, mut show_files) = (false, false, false); let mut i = 0; while i < args.len() { if i == command { @@ -185,7 +189,12 @@ fn parse_command( if (planning && matches!( key, - "--dry-run" | "--plan" | "--clean-staging" | "--on-existing" + "--dry-run" + | "--plan" + | "--clean-staging" + | "--on-existing" + | "--strict" + | "--show-files" )) || (!planning && key == "--show") { @@ -193,7 +202,12 @@ fn parse_command( } if matches!( key, - "--help" | "--dry-run" | "--no-config" | "--clean-staging" + "--help" + | "--dry-run" + | "--no-config" + | "--clean-staging" + | "--strict" + | "--show-files" ) { if inline.is_some() { return Err("flag does not accept a value"); @@ -202,6 +216,8 @@ fn parse_command( "--help" => help = true, "--dry-run" => dry_run = true, "--clean-staging" => clean_staging = true, + "--strict" => strict = true, + "--show-files" => show_files = true, _ => no_config = true, } continue; @@ -313,6 +329,8 @@ fn parse_command( dry_run, clean_staging, replace_managed, + strict, + show_files, plan: approved, })) } @@ -515,6 +533,14 @@ pub(crate) fn run( Err(message) => return fail(stderr, 3, message), } } + if options.strict && plan.unresolved_units > 0 { + writeln!( + stdout, + "Nothing written: {} units are unknown or unsupported (--strict).", + plan.unresolved_units + )?; + return Ok(1); + } if options.clean_staging && let Err(error) = migrate::clean_staging(&options.output) { @@ -547,6 +573,11 @@ pub(crate) fn run( if let Err(error) = migrate::execute(&plan, &destination) { return fail(stderr, code(error.kind()), &error.to_string()); } + if options.show_files { + for operation in &plan.operations { + write_operation(stdout, operation)?; + } + } writeln!( stdout, "Migrated: {} files written ({} converted to Astro, {} kept as scripts, {} copied, {} generated, {} preserved).", @@ -629,7 +660,7 @@ mod tests { "the only available target is astro-preact", ), ( - vec!["migrate", "--to", "astro-preact", "--out", "o", "--strict"], + vec!["migrate", "--to", "astro-preact", "--out", "o", "--report"], "option is not available in migrate v0", ), ( diff --git a/crates/frameshift-cli/tests/cli.rs b/crates/frameshift-cli/tests/cli.rs index 0405bc6..dc01bfd 100644 --- a/crates/frameshift-cli/tests/cli.rs +++ b/crates/frameshift-cli/tests/cli.rs @@ -121,7 +121,7 @@ fn invalid_arguments_and_planned_commands_fail_without_echoing_input() { &["plan", "--to", "astro-preact", "--show", "units"], &["plan", "--to", "astro-preact", "--dry-run"], &["migrate", "--to", "astro-preact"], - &["migrate", "--to", "astro-preact", "--out", "o", "--strict"], + &["migrate", "--to", "astro-preact", "--out", "o", "--report"], &["version", "extra"], &["help", "help", "extra"], &["--version", "version"], diff --git a/crates/frameshift-cli/tests/golden/help-migrate.txt b/crates/frameshift-cli/tests/golden/help-migrate.txt index 411fdbb..81ab13a 100644 --- a/crates/frameshift-cli/tests/golden/help-migrate.txt +++ b/crates/frameshift-cli/tests/golden/help-migrate.txt @@ -1,6 +1,6 @@ Usage: frameshift migrate [] [--to astro-preact] --out [--package ] - [--config |--no-config] [--plan ] [--dry-run] [--clean-staging] - [--on-existing fail|replace-managed] + [--config |--no-config] [--plan ] [--dry-run] [--show-files] [--strict] + [--clean-staging] [--on-existing fail|replace-managed] Migrate the Next.js App Router project at , by default the current directory, into a new Astro project with Preact at (migrate v0, D-101). Static pages, layouts, and components that the analysis rates auto or review are converted; everything else is @@ -9,7 +9,9 @@ copied unchanged below .frameshift/unmigrated/ and listed in .frameshift/SUMMARY staging directory next to and renamed into place when complete. The source is never modified, and no project code or external tool runs. --plan refuses to continue unless the recomputed plan document equals the given one byte for byte; the given file is compared, never -executed. --dry-run lists the planned files and writes nothing. --clean-staging first removes +executed. --dry-run lists the planned files and writes nothing, and --show-files lists them in +a real run. --strict writes nothing and exits with 1 if any unit is unknown or unsupported. +--clean-staging first removes the marked staging directory of an interrupted run next to . --on-existing replace-managed replaces an existing only if its manifest records it exactly, and moves the old tree next to it instead of deleting it. frameshift.jsonc in diff --git a/crates/frameshift-cli/tests/migrate.rs b/crates/frameshift-cli/tests/migrate.rs index 102d581..a802be6 100644 --- a/crates/frameshift-cli/tests/migrate.rs +++ b/crates/frameshift-cli/tests/migrate.rs @@ -326,6 +326,26 @@ fn replace_managed_replaces_only_unmodified_output() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn strict_refuses_unresolved_units_and_show_files_lists_a_real_run() { + let root = workspace("static-site", "strict"); + let mut strict = MIGRATE.to_vec(); + strict.push("--strict"); + // The fixture preserves a dynamic route that the analysis rates unsupported. + let refused = call(&root, &strict); + assert_eq!(refused.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&refused.stdout).starts_with("Nothing written: ")); + assert!(!root.join("out").exists()); + let mut show = MIGRATE.to_vec(); + show.push("--show-files"); + let listed = call(&root, &show); + assert_eq!(listed.status.code(), Some(0)); + let text = String::from_utf8(listed.stdout).unwrap(); + assert!(text.contains("layout.chain from app/layout.tsx"), "{text}"); + assert!(text.contains("Migrated: "), "{text}"); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn clean_staging_recovers_from_an_interrupted_run() { let root = workspace("static-site", "staging"); diff --git a/crates/frameshift-engine/src/migrate/mod.rs b/crates/frameshift-engine/src/migrate/mod.rs index 1f3dd79..6db8ea5 100644 --- a/crates/frameshift-engine/src/migrate/mod.rs +++ b/crates/frameshift-engine/src/migrate/mod.rs @@ -149,6 +149,9 @@ pub struct Plan { pub tasks: Vec, /// What the plan depends on. pub binding: Binding, + /// Assessed units rated `unknown` or `unsupported`, which `--strict` + /// refuses to commit. + pub unresolved_units: usize, hash: Sha256Digest, document: String, } @@ -1161,6 +1164,14 @@ impl<'a> Planner<'a> { operations, tasks, binding, + unresolved_units: self + .parts + .analysis + .assessment + .units + .iter() + .filter(|unit| matches!(unit.status, Status::Unknown | Status::Unsupported)) + .count(), hash: printed.hash, document: printed.document, }) diff --git a/docs/CLI.md b/docs/CLI.md index 6f05fb1..12b349c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -170,14 +170,14 @@ frameshift migrate [] --to --out | `--out ` | Required. The output directory. It must not exist or must be empty, must not be inside the source, must not contain the source, and must not be a filesystem root or the home directory. | | `--plan ` | Execute only if the recomputed plan document equals this file byte for byte (implemented, `D-102`). Otherwise exit with 3. The file is an approval artifact, not an instruction stream: it is compared, never parsed or executed. | | `--dry-run` | Run everything except the commit. Nothing is written unless `--report` is given. | -| `--show-files` | List every file operation | -| `--strict` | Abort before commit if any unit is `unsupported`, `unknown`, or `failed`. Nothing is written, and the exit code is 1. | +| `--show-files` | List every file operation (implemented) | +| `--strict` | Abort before commit if any unit is `unsupported`, `unknown`, or `failed` (implemented; no unit can be `failed` yet). Nothing is written, and the exit code is 1. | | `--env-names astro\|keep` | Naming of public environment variables (`D-021`). Default `astro` renames `NEXT_PUBLIC_X` to `PUBLIC_X`. `keep` preserves the original names. Also accepted by `plan`. | | `--config `, `--no-config` | Use a specific configuration file, or none (section 5.1, implemented, `D-103`). Also accepted by `analyze` and `plan`. | | `--on-existing fail\|replace-managed` | `fail` (default) refuses an existing output. `replace-managed` (implemented, `D-106`) replaces an existing output only if its manifest marks it as Frameshift output and no file in it was added, removed, or modified since generation; otherwise it exits with 4. The previous output moves to `..frameshift-previous` next to it and is never deleted; an existing tree at that name also exits with 4. | | `--clean-staging` | Remove a staging directory left by an interrupted run (implemented). Only real directories carrying Frameshift's staging marker are removed; anything else at that name exits with 4. It cannot be part of a `--dry-run`. | -**Implemented: migrate v0 (`D-101`).** `frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run] [--clean-staging] [--on-existing fail|replace-managed]` exists with text output only. `--out` must name a directory that does not exist, whose parent exists, and that is neither inside the source nor contains it. The command converts static pages, layouts, and components that the analysis rates `auto` or `review` with the `astro` or `preact-static` strategy, copies everything else unchanged below `.frameshift/unmigrated/`, and writes `.frameshift/SUMMARY.md` with the manual tasks. `--dry-run` prints every planned file with its operation kind, rule, and source, and writes nothing. A real run writes through a staging directory `..frameshift-staging` next to the output and prints counts; an existing staging directory refuses the run. `--plan`, `--strict`, `--on-existing`, `--clean-staging`, `--report`, `--show-files`, `--env-names`, `--config`, `--no-config`, and `--format json` remain planned and are usage errors. Exit statuses are 0, 2, 3, 4, 6, and 70 as in section 7; preserved files are reported results and do not produce 1 or 5. Structural verification, the manifest, and `report.json` are not implemented yet. Exact contracts are in section 52 of [IMPLEMENTATION.md](IMPLEMENTATION.md). +**Implemented: migrate v0 (`D-101`).** `frameshift migrate [] [--to astro-preact] --out [--package ] [--config |--no-config] [--plan ] [--dry-run] [--show-files] [--strict] [--clean-staging] [--on-existing fail|replace-managed]` exists with text output only. `--out` must name a directory that does not exist, whose parent exists, and that is neither inside the source nor contains it. The command converts static pages, layouts, and components that the analysis rates `auto` or `review` with the `astro` or `preact-static` strategy, copies everything else unchanged below `.frameshift/unmigrated/`, and writes `.frameshift/SUMMARY.md` with the manual tasks. `--dry-run` prints every planned file with its operation kind, rule, and source, and writes nothing. A real run writes through a staging directory `..frameshift-staging` next to the output and prints counts; an existing staging directory refuses the run. `--plan`, `--strict`, `--on-existing`, `--clean-staging`, `--report`, `--show-files`, `--env-names`, `--config`, `--no-config`, and `--format json` remain planned and are usage errors. Exit statuses are 0, 2, 3, 4, 6, and 70 as in section 7; preserved files are reported results and do not produce 1 or 5. Structural verification, the manifest, and `report.json` are not implemented yet. Exact contracts are in section 52 of [IMPLEMENTATION.md](IMPLEMENTATION.md). Structural verification always runs as part of `migrate`, because its checks are rule postconditions. A sample is in section 11.4 of [MIGRATION_REPORTING.md](MIGRATION_REPORTING.md). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index afedf28..fc746e4 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1947,7 +1947,7 @@ Output paths are validated as `OutputPath` values and compared without ASCII cas **Execution** (`crates/frameshift-output`, the only product crate that writes files). `prepare` canonicalizes the source and the output's parent, refuses an existing output, a missing parent, an output inside the source or containing it, and an existing staging directory `..frameshift-staging` next to the output. `write` creates that staging directory with a `.frameshift-staging` marker, creates every file with `create_new` and `sync_all`, removes the marker, checks that the output is still absent, and renames the staging directory into place. On Windows, an access-denied rename is retried up to 20 times with growing delays, because scanners can briefly hold the new files open; other errors fail at once. On any failure it removes only the staging directory it created. `clean_staging`, behind `migrate --clean-staging`, removes the staging directory of an interrupted run only when it is a real directory with the marker. Errors never contain paths. -**CLI.** `frameshift migrate [] --to astro-preact --out [--package ] [--dry-run]` parses arguments in `crates/frameshift-cli/src/migrate_cli.rs`, calls `plan`, `check_output`, and, without `--dry-run`, `execute`. The dry run prints every operation with its kind, escaped output path, rule, and source; a real run prints counts. Exit statuses: 0, 2 for usage errors (including every planned option), 3 for analysis input and limit failures, 4 for refused or failed output, 6 for stream failures, and 70 for internal errors. Preserved files do not change the status. +**CLI.** `frameshift migrate [] --to astro-preact --out [--package ] [--dry-run]` parses arguments in `crates/frameshift-cli/src/migrate_cli.rs`, calls `plan`, `check_output`, and, without `--dry-run`, `execute`. The dry run prints every operation with its kind, escaped output path, rule, and source; a real run prints counts. Exit statuses: 0, 1 when `--strict` finds a unit rated `unknown` or `unsupported`, 2 for usage errors (including every planned option), 3 for analysis input and limit failures, 4 for refused or failed output, 6 for stream failures, and 70 for internal errors. Without `--strict`, preserved files do not change the status. `--show-files` lists every operation of a real run. **Validation.** `fixtures/migrate/` holds two fictional cases with `expected/plan.txt`, `expected/output/`, and `expected/dry-run.txt`: `static-site` (root and nested layouts, a title template, page metadata, `next/link`, a route group, a dropped segment option, an Astro component, a static Preact component, a relocated module, a global stylesheet, public assets, and a preserved dynamic route and client page) and `fail-closed` (a `src/app` project where preservation propagates from a client component and a request-time module to the pages that import them, a page with a dynamic `import()` of a project file, a page that binds the generated layout name, pages and a component whose function bindings clash with or are read by module declarations, a module that reads `import.meta`, and declaration files with a relative ambient module or a triple-slash path reference are preserved, and an optional dependency is kept); `static-site` also preserves an out-of-scope test file, rewrites type-only alias imports in a module and a declaration file, keeps a wildcard ambient declaration, and converts an async page whose body statements read a later module declaration, a component with a `module-per-render` review, an arrow page behind `export default `, a page with an expression statement, and an arrow component with a default prop value. Both expected outputs were built on 2026-11-01 with the pinned `astro7-preact10` profile outside the repository: `static-site` produced 8 pages and `fail-closed` 1 page, without a `script` tag in any page. `crates/frameshift-engine/tests/migrate.rs` plans twice and compares byte for byte, executes into a temporary directory, and checks that the written tree equals the plan, that the source is byte-identical afterwards, that nothing else was created, and that unsafe outputs are refused. `crates/frameshift-cli/tests/migrate.rs` checks the dry-run text, the written tree, and exit statuses. Goldens change only through: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 79dfadc..22dbf4c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Each milestone ends with a capability that is demonstrable and tested. | **M4** Semantic analysis and project model (complete) | Facts about any App Router project | Snapshot-only local module resolution, inherited `tsconfig`/`jsconfig` paths, and a deterministic import graph are implemented (`D-091`, IMPLEMENTATION.md section 37). Package `imports`, self-references, and directory entry points with per-edge resolution modes follow (`D-092`, section 38). Scopes, bindings, references, imports, and exports of each file follow (`D-092`, section 39), as do the App Router route tree, convention exports, and render contexts (section 40), and the static evaluator with experiment E3 (section 41). A separate Node10 resolution profile for `"moduleResolution": "node"` is implemented (`D-093`, section 42); ordered module filename suffixes are implemented in both profiles (`D-094`, section 43); the assembled source model with inventory, per-module binding and render evidence, direct environment uses, bounded route closures, and a golden snapshot is implemented (`D-095`, section 44); the component graph resolves render sites through bindings, module edges, and recognized wrappers, and environment classification records where modules run and which variables and runtime APIs they use (`D-096`, sections 45 and 46); `tsconfig`/`jsconfig` inheritance from linked workspace packages is implemented (`D-098`, section 48). Module and component graphs. Route tree. Render contexts. Environment classification. Static evaluator (with experiment E3 measuring evaluation coverage). Next.js configuration, convention-export, and direct-import API facts are implemented (`D-097`, section 47), preserving unknown values and bundler evidence. Golden snapshots of the project model. M4 closed under `D-099` (IMPLEMENTATION.md section 49): custom bundler rules stay configuration evidence because V1 assesses them as `unsupported`, and indirect API value flow and component values from higher-order components and factories stay explicit unknown evidence. | M2, M3 | | **M5** Assessment and analysis report (implemented) | `frameshift analyze`: **first user value** | Feature catalog for the V1 matrix, implemented as embedded data with the detection of feature occurrences and their fixed statuses (`D-099`, IMPLEMENTATION.md section 50). Read-only assessment predicates, counting ownership and accounting, transitive route readiness with witness paths, a versioned knowledge base, corpus-validated `weights-v1`, exact integer metrics, `frameshift.analysis` v1 with bounded stored parsing, text/JSON/Markdown projections, `analyze`, `report`, `explain`, assessment `rules`, and fact-based verdicts are implemented under `D-100` (IMPLEMENTATION.md section 51). Unknown preconditions remain visible; no transformation coverage is claimed. | M4 | | **M6** Planning (implemented) | `frameshift plan` | Configuration file `frameshift.jsonc` with its JSON Schema. Output path mapping for the conventional Astro layout, with collision detection. Rule registry with exclusivity checks. Operation generation. Plan document, hashing, and binding. Manual tasks. `migrate --dry-run` file listing. Migrate v0 (`D-101`) implements the output path mapping with collision detection, operations, manual tasks, and the dry-run listing as a pure function of the analysis, first (IMPLEMENTATION.md section 52). The plan document, its hash and binding, and `frameshift plan` are implemented (`D-102`, section 53), as are the configuration file (`D-103`, section 54) and the rule registry with its exclusivity check (`D-104`, section 55). | M5 | -| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest and the `replace-managed` overwrite policy are implemented (`D-105`, `D-106`, IMPLEMENTATION.md sections 56 and 57). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | +| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest and the `replace-managed` overwrite policy are implemented (`D-105`, `D-106`, IMPLEMENTATION.md sections 56 and 57). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, `--strict`, `--show-files`, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | | **M8** Dynamic routes, assets, styling | Common content sites | Dynamic routes with static parameters (including composition), catch-all routes, root not-found, special files, static GET route handlers, generated metadata routes, `next/image` (both strategies), `next/font`, `next/script`, Tailwind CSS v3 and v4, PostCSS, environment variable rewriting (`Q-003` resolved), metadata helper for non-literal metadata. | M7 | | **M9** Client components and islands | Interactive sites | Island selection, `client:load`, `client:only` for `ssr: false`, the compat strategy with package aliases and overrides for every supported package manager, boundary prop analysis and named slots, navigation helper, context scope classification, hook coverage, knowledge base entries for curated React libraries backed by fixtures. | M7 | | **M10** Verification | `frameshift verify`, including runtime | External tool runner (authorization, temporary copies, environment scrubbing, timeouts, log capture). Build level. Static output level with HTML normalization and per-route comparison (experiment E4 decides the HTML parser, `Q-005`). Runtime hydration, declared interaction, and selected accessibility checks using a user-installed browser. Decide browser tooling and the versioned check protocol here. Per-unit verification evidence. | M7. Full value after M8 and M9. | From afc06cf431c9ce50a3f008286df6711ce2ebbf94 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:54:18 +0100 Subject: [PATCH 07/11] feat(migrate): check generated scripts structurally before planning ends A generated script or Astro frontmatter must parse again, its runtime imports must name planned files or packages other than Next.js, and no React directive may remain. A file that fails is preserved with its reason, so a rule defect cannot reach the output (D-107). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- crates/frameshift-engine/src/migrate/mod.rs | 21 ++ .../src/migrate/structure.rs | 256 ++++++++++++++++++ docs/DECISIONS.md | 9 + docs/IMPLEMENTATION.md | 6 +- docs/ROADMAP.md | 2 +- 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 crates/frameshift-engine/src/migrate/structure.rs diff --git a/AGENTS.md b/AGENTS.md index 4124909..8e89cfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ These instructions are binding for every agent working on Frameshift, whatever t - **M5 analysis (`D-100`).** `frameshift_engine::analysis` owns read-only assessment, counting/file accounting, transitive readiness, knowledge, exact metrics, and `frameshift.analysis` v1. Reuse `project::build_with`; no identifier-spelling inference or source execution. Unknown preconditions stay unknown, and assessment predicates never count as transformation coverage. Stored reports are bounded, validated data and never authorize reading their paths or executing operations. The CLI exposes `analyze`, stored `report`, `explain`, and assessment `rules`; explicit analysis report outputs are new files outside the source. Golden updates use `cargo test -p frameshift-engine --offline --locked --test analysis -- --ignored update_analysis_golden`. Corpus and reference-fixture commands, limits, current inference boundaries, and validated weights are in IMPLEMENTATION.md section 51. -- **Migrate v0 (`D-101`).** `frameshift_engine::migrate` plans only from `analysis::analyze_parts` (the analysis plus its snapshot, project model, and manifest); never read the source again or write outside `frameshift-output`. A conversion that moves bindings into the frontmatter scope must check them against that scope, and a relocation must refuse what the module graph has no edge for. A stored plan document is only compared with the recomputed one, never parsed or interpreted (`D-102`). Every operation names a rule in `migrate/rules.rs` of its own kind, and one source file reaches one rule; raise a rule's version whenever its output can change (`D-104`). The manifest depends on the plan hash, so it stays outside the plan document (`D-105`). Replacing an output requires an exact manifest match and moves the old tree aside, never deleting it (`D-106`). Convert only units with status `auto` or `review` and strategy `astro` or `preact-static`; preserve everything else below `.frameshift/unmigrated/` with a manual task, and keep the preservation fixed point so the output never imports a file that was not written. Every rule refusal is a preserved file with a reason, never a guessed conversion. Fixtures are in `fixtures/migrate/` (IMPLEMENTATION.md section 52). +- **Migrate v0 (`D-101`).** `frameshift_engine::migrate` plans only from `analysis::analyze_parts` (the analysis plus its snapshot, project model, and manifest); never read the source again or write outside `frameshift-output`. A conversion that moves bindings into the frontmatter scope must check them against that scope, and a relocation must refuse what the module graph has no edge for. A stored plan document is only compared with the recomputed one, never parsed or interpreted (`D-102`). Every operation names a rule in `migrate/rules.rs` of its own kind, and one source file reaches one rule; raise a rule's version whenever its output can change (`D-104`). The manifest depends on the plan hash, so it stays outside the plan document (`D-105`). Replacing an output requires an exact manifest match and moves the old tree aside, never deleting it (`D-106`). `migrate/structure.rs` is a net for rule defects, not a substitute for a rule's own checks (`D-107`). Convert only units with status `auto` or `review` and strategy `astro` or `preact-static`; preserve everything else below `.frameshift/unmigrated/` with a manual task, and keep the preservation fixed point so the output never imports a file that was not written. Every rule refusal is a preserved file with a reason, never a guessed conversion. Fixtures are in `fixtures/migrate/` (IMPLEMENTATION.md section 52). - **Component graph (`D-096`).** `frameshift_engine::project::Project::components` resolves render sites through bindings and the module graph's own edges (IMPLEMENTATION.md section 45). Recognize wrappers (`memo`, `forwardRef`, `lazy`, `next/dynamic`) only through binding provenance of their imports, confirmed by the import edge's package resolution, never by callee spelling; every other call is a `Call` reason. Follow `export *` with ECMAScript's rules and keep several or unknowable providers `AmbiguousStar`. Linking charges `modules.max-work` and returns no partial graph. Its golden changes only through `cargo test -p frameshift-engine --offline --locked --test components -- --ignored update_components_golden`. diff --git a/crates/frameshift-engine/src/migrate/mod.rs b/crates/frameshift-engine/src/migrate/mod.rs index 6db8ea5..6167627 100644 --- a/crates/frameshift-engine/src/migrate/mod.rs +++ b/crates/frameshift-engine/src/migrate/mod.rs @@ -14,6 +14,7 @@ mod managed; mod plan_document; pub mod rules; mod scaffold; +mod structure; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; @@ -807,6 +808,26 @@ impl<'a> Planner<'a> { Err(reason) => refused.push((path.clone(), reason)), } } + if refused.is_empty() { + let outputs: BTreeSet = self + .fates + .values() + .filter_map(|fate| match fate { + Fate::Convert(candidate) => Some(candidate.output.clone()), + Fate::Preserve(_) | Fate::NotCopied(_) | Fate::Gitignore => None, + }) + .collect(); + for (path, output) in &rendered { + let Some(Fate::Convert(candidate)) = self.fates.get(path) else { + continue; + }; + if let Err(reason) = + structure::check(&candidate.output, &output.bytes, &outputs, self.limits) + { + refused.push((path.clone(), reason)); + } + } + } if refused.is_empty() { return Ok(rendered); } diff --git a/crates/frameshift-engine/src/migrate/structure.rs b/crates/frameshift-engine/src/migrate/structure.rs new file mode 100644 index 0000000..d251dfd --- /dev/null +++ b/crates/frameshift-engine/src/migrate/structure.rs @@ -0,0 +1,256 @@ +//! The structural postcondition of every generated script (`D-107`). +//! +//! A file that fails these checks is preserved instead of written, so a rule +//! defect cannot produce output that no longer parses, imports a file that +//! the plan does not write, or still depends on the source framework. The +//! template of an Astro file is outside this check: it needs a parser that +//! does not exist yet. + +use std::collections::BTreeSet; + +use frameshift_core::SourceText; +use frameshift_syntax::{ + JsDialect, NodeId, SourceGoal, SyntaxNodeKind, SyntaxTree, TypeScriptKind, parse_js_program, +}; + +use crate::limits::Limits; + +// Extensions that an extensionless relative specifier can resolve to. +const SCRIPT_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "js", "jsx", "mjs", "d.ts"]; + +// The frontmatter of a generated Astro file, without its fences. A file +// without an opening fence is all template, and an unclosed one is an error. +fn frontmatter(text: &str) -> Result, String> { + let Some(rest) = text + .strip_prefix("---\r\n") + .or_else(|| text.strip_prefix("---\n")) + else { + return Ok(None); + }; + rest.match_indices("---") + .find(|(index, _)| *index == 0 || rest.as_bytes()[index - 1] == b'\n') + .map(|(end, _)| Some(&rest[..end])) + .ok_or_else(|| "the generated Astro file has no closed frontmatter".to_owned()) +} + +// The dialect and source text of a generated file, if it is a script. +fn script<'t>(output: &str, text: &'t str) -> Result, String> { + let dialect = if output.ends_with(".astro") { + return Ok(frontmatter(text)?.map(|code| (JsDialect::TypeScript, code))); + } else if output.ends_with(".d.ts") { + JsDialect::TypeScriptDeclaration + } else if output.ends_with(".tsx") { + JsDialect::Tsx + } else if output.ends_with(".ts") { + JsDialect::TypeScript + } else if output.ends_with(".jsx") || output.ends_with(".js") || output.ends_with(".mjs") { + JsDialect::Jsx + } else { + return Ok(None); + }; + Ok(Some((dialect, text))) +} + +/// Checks one generated file against the set of output paths that the plan +/// writes. Files that are not scripts or Astro components have no structure +/// to check here. +pub(super) fn check( + output: &str, + bytes: &[u8], + outputs: &BTreeSet, + limits: &Limits, +) -> Result<(), String> { + let Ok(text) = std::str::from_utf8(bytes) else { + // Assets may hold any bytes; a generated script never does. + return if script(output, "---\n---\n")?.is_some() { + Err("the generated file is not UTF-8".to_owned()) + } else { + Ok(()) + }; + }; + let Some((dialect, code)) = script(output, text)? else { + return Ok(()); + }; + let source = SourceText::new(code.to_owned(), limits.text) + .map_err(|_| "the generated file exceeds the text limit".to_owned())?; + let tree = parse_js_program(&source, SourceGoal::Module, dialect, limits.parse) + .map_err(|_| "the generated file does not parse again".to_owned())?; + // Declaration files only describe types and keep the source's references. + if dialect == JsDialect::TypeScriptDeclaration { + return Ok(()); + } + visit(&tree, tree.root(), output, outputs) +} + +fn visit( + tree: &SyntaxTree<'_>, + id: NodeId, + output: &str, + outputs: &BTreeSet, +) -> Result<(), String> { + let Some(node) = tree.node(id) else { + return Err("a syntax node is outside the tree".to_owned()); + }; + let kind = node.kind(); + // Types do not exist at run time, so they can neither break nor keep a dependency. + if matches!( + kind, + SyntaxNodeKind::TypeScript { + kind: TypeScriptKind::TypeOnly, + .. + } + ) { + return Ok(()); + } + if matches!(kind, SyntaxNodeKind::Directive { .. }) { + let directive = tree + .source() + .slice(node.span()) + .map_err(|_| "a syntax span is outside the source".to_owned())?; + if directive.contains("use client") || directive.contains("use server") { + return Err("the generated file keeps a React directive".to_owned()); + } + } + let source = if let SyntaxNodeKind::ImportDeclaration { source, .. } + | SyntaxNodeKind::ExportAll { source, .. } + | SyntaxNodeKind::ImportCall { source, .. } = kind + { + Some(source) + } else if let SyntaxNodeKind::ExportNamed { source, .. } = kind { + source + } else { + None + }; + if let Some(specifier) = source.and_then(|source| tree.string_literal_value(source)) { + check_specifier(&specifier, output, outputs)?; + } + for child in tree.child_nodes(id).into_iter().flatten() { + visit(tree, child, output, outputs)?; + } + Ok(()) +} + +fn check_specifier( + specifier: &str, + output: &str, + outputs: &BTreeSet, +) -> Result<(), String> { + if specifier == "next" || specifier.starts_with("next/") { + return Err("the generated file still imports Next.js".to_owned()); + } + if !(specifier.starts_with("./") || specifier.starts_with("../")) { + return Ok(()); + } + let Some(target) = join(output, specifier) else { + return Err("a generated import leaves the output".to_owned()); + }; + let resolves = outputs.contains(&target) + || SCRIPT_EXTENSIONS.iter().any(|extension| { + outputs.contains(&format!("{target}.{extension}")) + || outputs.contains(&format!("{target}/index.{extension}")) + }); + if resolves { + Ok(()) + } else { + Err("a generated import names a file that the plan does not write".to_owned()) + } +} + +// The output path that a relative specifier names from the file at `output`. +fn join(output: &str, specifier: &str) -> Option { + let mut parts: Vec<&str> = output.split('/').collect(); + parts.pop(); + for part in specifier.split('/') { + match part { + "." | "" => {} + ".." => { + parts.pop()?; + } + _ => parts.push(part), + } + } + Some(parts.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(output: &str, text: &str) -> Result<(), String> { + let outputs: BTreeSet = [ + "src/lib/site.ts", + "src/lib/index.ts", + "src/layouts/RootLayout.astro", + "src/styles/a.css", + ] + .into_iter() + .map(str::to_owned) + .collect(); + check(output, text.as_bytes(), &outputs, &Limits::default()) + } + + #[test] + fn accepts_what_the_rules_generate() { + for (output, text) in [ + ( + "src/pages/index.astro", + "---\nimport { site } from '../lib/site';\nimport RootLayout from '../layouts/RootLayout.astro';\nimport '../styles/a.css';\nconst a = await Promise.resolve(site);\n---\n

{a}

\n", + ), + ( + "src/layouts/Bare.astro", + "\n", + ), + ( + "src/pages/x.astro", + "---\r\nconst a = 1;\r\n---\r\n

\r\n", + ), + ( + "src/lib/other.ts", + "export { site } from './site';\nexport * from '.';\n", + ), + ( + "src/lib/kept.tsx", + "import type { Metadata } from 'next';\nexport const a =

;\n", + ), + ( + "src/types/a.d.ts", + "import type { X } from 'next';\nimport { Y } from './missing';\n", + ), + ("src/styles/b.css", "not a script {"), + ("public/a.bin", "\u{0}\u{1}"), + ] { + assert_eq!(run(output, text), Ok(()), "{output}"); + } + } + + #[test] + fn refuses_broken_files_without_echoing_them() { + for (output, text) in [ + ("src/pages/a.astro", "---\nconst a = 1;\n"), + ( + "src/pages/a.astro", + "---\nconst = secret-name;\n---\n

\n", + ), + ("src/lib/a.ts", "export const = secret-name;\n"), + ( + "src/lib/a.ts", + "import x from 'next/link';\nexport { x };\n", + ), + ("src/lib/a.ts", "import x from 'next';\nexport { x };\n"), + ("src/lib/a.ts", "export * from './secret-name';\n"), + ( + "src/lib/a.ts", + "export { a } from '../../../../secret-name';\n", + ), + ("src/lib/a.ts", "const a = import('./secret-name');\n"), + ( + "src/pages/a.astro", + "---\nimport a from '../lib/secret-name';\n---\n

\n", + ), + ("src/lib/a.tsx", "'use client';\nexport const a = 1;\n"), + ] { + let error = run(output, text).expect_err(text); + assert!(!error.contains("secret-name"), "{text}: {error}"); + } + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0a95bf2..22819e3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -114,6 +114,7 @@ Statuses change through an edit to this file under the integration workflow in D | D-104 | The rule registry of migrate | Accepted | | D-105 | The output manifest | Accepted | | D-106 | Replacing managed output | Accepted | +| D-107 | The structural postcondition of generated scripts | Accepted | | P-001 | Go as the single implementation language, CGO disabled | Superseded by D-018 | | P-002 | Purpose-built parsers | Accepted | | P-003 | Span-based edits and targeted printers; no concrete syntax tree initially | Accepted | @@ -1227,6 +1228,14 @@ Statuses change through an edit to this file under the integration workflow in D - **Tradeoffs:** A user must remove the moved tree before the next replacement. The manifest is not authenticated, so someone who edits both a file and its digest can pass the check; the policy protects against accidental loss, not against an attacker with write access to the output. - **Revisit if:** Repeated regeneration makes removing the moved tree a burden. +### D-107: The structural postcondition of generated scripts + +- **Status:** Accepted (2026-11-01), implementing the postconditions of section 12.2 of [ARCHITECTURE.md](ARCHITECTURE.md) for the rules that exist. +- **Decision:** Before a plan is final, every generated script and the frontmatter of every generated Astro file must parse again in a strict parse. A runtime import, re-export, or literal dynamic import must not name `next` or `next/*`, and a relative one must name a file that the plan writes. A `use client` or `use server` directive must not remain. A file that fails is preserved with its reason, and preservation propagates as usual. Type-only imports and declaration files are not checked for imports, because they do not exist at run time. +- **Rationale:** The rules are designed to make these failures impossible, so this is a net for rule defects. Preserving keeps a defect from reaching the output and reports it as a manual task, as the fail-closed rule requires. +- **Tradeoffs:** The Astro template is not re-parsed, because no template parser exists. A failure appears as a preserved file with a generic reason, not as a `failed` unit or `FS5001`, because units have no such state yet. `frameshift verify` remains planned. +- **Revisit if:** M10 adds an Astro template parser and `verify`. + ## Technique proposals and related accepted decisions diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fc746e4..f0c9e15 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1990,8 +1990,12 @@ Tests check that the registry is sorted and unique, that every identifier passes `plan_document::print` also renders the `frameshift.manifest` document, and `Planner::finish` inserts it as the operation `.frameshift/manifest.json` with the rule `report.manifest`, keeping operations sorted by output path. The plan document and hash cover every other operation; the manifest names that hash, so it cannot be part of it. Goldens replace the engine version and the plan hash with placeholders. The manifest lists no timestamps or absolute paths. `--on-existing replace-managed` reads it (section 57), and `verify` remains planned. -**Real-world check (2026-11-01).** On the 211 App Router package directories of the approved pinned D-043 corpus, read-only and outside the repository, `plan` exited with 0 for every package. Two plans of one package were byte-identical, a `migrate --plan` run succeeded for each, every written file (12,954 in total) matched its manifest digest, and the sources sampled before and after each run were unchanged. No project code ran. The plans held 79 `create`, 227 `transform`, 3,658 `copy`, 1,055 `config`, and 7,724 `preserve` operations. Of the preserved files, 5,408 are rated `unknown`, 1,103 are client components, and 418 are rated `unsupported`. Across the corpus, `FS3001` (`source.unknown`) occurs on 4,100 files and `FS3302` (unclassified third-party packages) on 2,695, and one file can carry both. Two sources dominate the unknown occurrences, and neither is a resolver defect: this check started each package directory as its own root, so a member whose `tsconfig.json` extends a workspace package loses that configuration (`Unresolved(Configuration)`, 6,071 of 6,885 in one member, gone with the workspace root and `--package`); and one project imports through `@/*` into a directory that its checkout lacks (`Unresolved(InvalidSpecifier)`, 4,612 of 4,693). The resolver reports the second case as an invalid specifier because `@` alone is no package scope, although the path mapping's missing target is the real cause. The corpus is deliberately broad and includes applications that are poor candidates for static migration, so these numbers measure robustness and fail-closed behavior, not typical conversion yield. +**Real-world check (2026-11-01).** On the 211 App Router package directories of the approved pinned D-043 corpus, read-only and outside the repository, `plan` exited with 0 for every package. Two plans of one package were byte-identical, a `migrate --plan` run succeeded for each, every written file (12,954 in total) matched its manifest digest, and the sources sampled before and after each run were unchanged. No project code ran. The plans held 79 `create`, 222 `transform`, 3,658 `copy`, 1,055 `config`, 211 manifest, and 7,729 `preserve` operations. The structural postcondition (section 58) preserved 5 files that would have kept a React directive, and refused nothing else. Of the preserved files, 5,408 are rated `unknown`, 1,103 are client components, and 418 are rated `unsupported`. Across the corpus, `FS3001` (`source.unknown`) occurs on 4,100 files and `FS3302` (unclassified third-party packages) on 2,695, and one file can carry both. Two sources dominate the unknown occurrences, and neither is a resolver defect: this check started each package directory as its own root, so a member whose `tsconfig.json` extends a workspace package loses that configuration (`Unresolved(Configuration)`, 6,071 of 6,885 in one member, gone with the workspace root and `--package`); and one project imports through `@/*` into a directory that its checkout lacks (`Unresolved(InvalidSpecifier)`, 4,612 of 4,693). The resolver reports the second case as an invalid specifier because `@` alone is no package scope, although the path mapping's missing target is the real cause. The corpus is deliberately broad and includes applications that are poor candidates for static migration, so these numbers measure robustness and fail-closed behavior, not typical conversion yield. ## 57. Replacing managed output (`D-106`) `migrate::check_replacement` treats an absent location as new. For an existing one, `frameshift_output::prepare_replacement` first refuses anything but a real directory outside the source, then `migrate/managed.rs` walks it with `frameshift_output::visit_tree` (no links or special entries, bounded by the run limits) and compares its file set and digests with the manifest. `Destination::commit` moves the old tree to `..frameshift-previous`, moves the staging directory into place, and moves the old tree back if that fails. A failed write in the staging directory leaves the old tree untouched. + +## 58. Structural postcondition (`D-107`) + +After the rules converge, `migrate/structure.rs` re-parses every generated script and the frontmatter of every generated Astro file, and walks runtime imports, re-exports, and literal dynamic imports: `next` and `next/*` are refused, a relative specifier must name a planned output (exactly, with a script extension, or as a directory index), and `use client` and `use server` directives are refused. A failing file becomes `Preserve` with its reason, and the convergence loop runs again, so importers follow. Type-only imports and declaration files are exempt from the import checks. The Astro template is outside this check. Unit tests in the module cover accepted and refused shapes, and every fixture and the corpus check of section 52 pass through it. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 22dbf4c..64c3c08 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Each milestone ends with a capability that is demonstrable and tested. | **M4** Semantic analysis and project model (complete) | Facts about any App Router project | Snapshot-only local module resolution, inherited `tsconfig`/`jsconfig` paths, and a deterministic import graph are implemented (`D-091`, IMPLEMENTATION.md section 37). Package `imports`, self-references, and directory entry points with per-edge resolution modes follow (`D-092`, section 38). Scopes, bindings, references, imports, and exports of each file follow (`D-092`, section 39), as do the App Router route tree, convention exports, and render contexts (section 40), and the static evaluator with experiment E3 (section 41). A separate Node10 resolution profile for `"moduleResolution": "node"` is implemented (`D-093`, section 42); ordered module filename suffixes are implemented in both profiles (`D-094`, section 43); the assembled source model with inventory, per-module binding and render evidence, direct environment uses, bounded route closures, and a golden snapshot is implemented (`D-095`, section 44); the component graph resolves render sites through bindings, module edges, and recognized wrappers, and environment classification records where modules run and which variables and runtime APIs they use (`D-096`, sections 45 and 46); `tsconfig`/`jsconfig` inheritance from linked workspace packages is implemented (`D-098`, section 48). Module and component graphs. Route tree. Render contexts. Environment classification. Static evaluator (with experiment E3 measuring evaluation coverage). Next.js configuration, convention-export, and direct-import API facts are implemented (`D-097`, section 47), preserving unknown values and bundler evidence. Golden snapshots of the project model. M4 closed under `D-099` (IMPLEMENTATION.md section 49): custom bundler rules stay configuration evidence because V1 assesses them as `unsupported`, and indirect API value flow and component values from higher-order components and factories stay explicit unknown evidence. | M2, M3 | | **M5** Assessment and analysis report (implemented) | `frameshift analyze`: **first user value** | Feature catalog for the V1 matrix, implemented as embedded data with the detection of feature occurrences and their fixed statuses (`D-099`, IMPLEMENTATION.md section 50). Read-only assessment predicates, counting ownership and accounting, transitive route readiness with witness paths, a versioned knowledge base, corpus-validated `weights-v1`, exact integer metrics, `frameshift.analysis` v1 with bounded stored parsing, text/JSON/Markdown projections, `analyze`, `report`, `explain`, assessment `rules`, and fact-based verdicts are implemented under `D-100` (IMPLEMENTATION.md section 51). Unknown preconditions remain visible; no transformation coverage is claimed. | M4 | | **M6** Planning (implemented) | `frameshift plan` | Configuration file `frameshift.jsonc` with its JSON Schema. Output path mapping for the conventional Astro layout, with collision detection. Rule registry with exclusivity checks. Operation generation. Plan document, hashing, and binding. Manual tasks. `migrate --dry-run` file listing. Migrate v0 (`D-101`) implements the output path mapping with collision detection, operations, manual tasks, and the dry-run listing as a pure function of the analysis, first (IMPLEMENTATION.md section 52). The plan document, its hash and binding, and `frameshift plan` are implemented (`D-102`, section 53), as are the configuration file (`D-103`, section 54) and the rule registry with its exclusivity check (`D-104`, section 55). | M5 | -| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest and the `replace-managed` overwrite policy are implemented (`D-105`, `D-106`, IMPLEMENTATION.md sections 56 and 57). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, `--strict`, `--show-files`, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | +| **M7** Execution and static sites | `frameshift migrate` for sites without client components | Executor: staging, atomic commit, overwrite policies, manifest, staging cleanup. Edit engine and Astro printer from E2. Structural verification. Rules: static routes, segments, groups, private folders, layout chain, static metadata and metadata files, Astro and static Preact components, `next/link`, global CSS, CSS Modules, Sass, public and imported assets, configuration generation, preservation of unmigrated files, `.frameshift/SUMMARY.md`. The converter accepts every function that the analysis rates Astro-convertible, through `convert_component`, except pages that read `params`. The output manifest, the `replace-managed` overwrite policy, and a structural postcondition on generated scripts are implemented (`D-105`, `D-106`, `D-107`, IMPLEMENTATION.md sections 56 to 58). Migrate v0 (`D-101`) implements staging with an atomic rename, cleanup of its own staging directory, `--clean-staging` for an interrupted run, `--strict`, `--show-files`, the E2 edit engine and Astro printer as engine rules for static routes, segments, groups, private folders, the layout chain, static `title` and `description` metadata, Astro and static Preact components, `next/link`, global CSS, public assets, configuration generation, preservation, and `.frameshift/SUMMARY.md`; overwrite policies, the manifest, structural verification, metadata files, CSS Modules, Sass, and imported assets remain open. | M6 (and E2) | | **M8** Dynamic routes, assets, styling | Common content sites | Dynamic routes with static parameters (including composition), catch-all routes, root not-found, special files, static GET route handlers, generated metadata routes, `next/image` (both strategies), `next/font`, `next/script`, Tailwind CSS v3 and v4, PostCSS, environment variable rewriting (`Q-003` resolved), metadata helper for non-literal metadata. | M7 | | **M9** Client components and islands | Interactive sites | Island selection, `client:load`, `client:only` for `ssr: false`, the compat strategy with package aliases and overrides for every supported package manager, boundary prop analysis and named slots, navigation helper, context scope classification, hook coverage, knowledge base entries for curated React libraries backed by fixtures. | M7 | | **M10** Verification | `frameshift verify`, including runtime | External tool runner (authorization, temporary copies, environment scrubbing, timeouts, log capture). Build level. Static output level with HTML normalization and per-route comparison (experiment E4 decides the HTML parser, `Q-005`). Runtime hydration, declared interaction, and selected accessibility checks using a user-installed browser. Decide browser tooling and the versioned check protocol here. Per-unit verification evidence. | M7. Full value after M8 and M9. | From 5376ff436c8fd26a02cbc8dc6aa693cfe43c15c0 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:59:31 +0100 Subject: [PATCH 08/11] fix(emit): refuse comments beside a parenthesized component root A comment between the parentheses and the markup of a returned root lay inside the root's span, so the conversion treated it as checked, but the markup conversion only reads what the parentheses enclose, and the comment was lost. The fuzzer's astro target found it. Co-Authored-By: Claude Opus 5.5 --- crates/frameshift-emit/src/component.rs | 9 ++++++++- crates/frameshift-engine/tests/astro.rs | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/frameshift-emit/src/component.rs b/crates/frameshift-emit/src/component.rs index a4bb309..1387572 100644 --- a/crates/frameshift-emit/src/component.rs +++ b/crates/frameshift-emit/src/component.rs @@ -117,7 +117,9 @@ pub fn convert_component( (Vec::new(), body, None) }; let root_span = checker.span(root)?; - let root_kind = checker.kind(checker.unparenthesize(root)?)?; + let markup_root = checker.unparenthesize(root)?; + let markup_span = checker.span(markup_root)?; + let root_kind = checker.kind(markup_root)?; if !matches!( root_kind, SyntaxNodeKind::JsxElement { .. } | SyntaxNodeKind::JsxFragment { .. } @@ -204,6 +206,11 @@ pub fn convert_component( if substitutions.iter().any(within) { return Err(checker.error(ConvertErrorKind::CommentInMarkup, span)); } + // The markup conversion checks only what the parentheses around the + // root enclose, so a comment beside the root would be lost. + if within(&root_span) && !within(&markup_span) { + return Err(checker.error(ConvertErrorKind::CommentInMarkup, span)); + } if within(&root_span) || statement_spans.iter().any(within) || pattern_copied { continue; } diff --git a/crates/frameshift-engine/tests/astro.rs b/crates/frameshift-engine/tests/astro.rs index 1f627b5..81d4d9d 100644 --- a/crates/frameshift-engine/tests/astro.rs +++ b/crates/frameshift-engine/tests/astro.rs @@ -592,6 +592,14 @@ fn component_props_and_statements_become_frontmatter() { #[test] fn unsupported_component_bodies_fail_closed() { let cases = [ + ( + "function P() { return ( /* lost */

); }", + ConvertErrorKind::CommentInMarkup, + ), + ( + "const P = () => (

/* lost */);", + ConvertErrorKind::CommentInMarkup, + ), ( "async function P({ x }) { const a = await x; if (!a) return null; return

{a}

; }", ConvertErrorKind::UnsupportedBody, From 4c2d189557aa46233e8bfe560e3ba14e5e162fe5 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:08:09 +0100 Subject: [PATCH 09/11] docs: record the fuzzing budget of the component conversion Co-Authored-By: Claude Opus 5.5 --- docs/IMPLEMENTATION.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index f0c9e15..f0520e3 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1999,3 +1999,5 @@ Tests check that the registry is sorted and unique, that every identifier passes ## 58. Structural postcondition (`D-107`) After the rules converge, `migrate/structure.rs` re-parses every generated script and the frontmatter of every generated Astro file, and walks runtime imports, re-exports, and literal dynamic imports: `next` and `next/*` are refused, a relative specifier must name a planned output (exactly, with a script extension, or as a directory index), and `use client` and `use server` directives are refused. A failing file becomes `Preserve` with its reason, and the convergence loop runs again, so importers follow. Type-only imports and declaration files are exempt from the import checks. The Astro template is outside this check. Unit tests in the module cover accepted and refused shapes, and every fixture and the corpus check of section 52 pass through it. + +**Fuzzing check (2026-11-01).** A 300-second budget of the `astro` target found that `convert_component` lost a comment between the parentheses and the markup of a returned root; such a comment now fails closed. After the fix, the target completed 4,624,922 executions in 300 seconds without findings, and the `json` target, which `frameshift.jsonc` now exposes to untrusted input, completed 6,327,435 executions in 180 seconds without findings. From 99c80d74217277d2908cfe7c44114dfb0576faba Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:08:31 +0100 Subject: [PATCH 10/11] docs: update the status lines for M6 and M7 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e89cfe..6db1e91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ These instructions are binding for every agent working on Frameshift, whatever t - **Identity.** The project is named Frameshift, with exactly this capitalization. The CLI executable and command is `frameshift`. Do not introduce other names, temporary branding, or package identities. Naming conventions are in `docs/ENGINEERING.md` (section 8). - **Purpose.** A deterministic, rule-based engine that analyzes software projects and migrates them between frameworks while preserving behavior, with transparent reporting. The first migration path is Next.js (App Router) to Astro with Preact islands, as static output (`docs/MIGRATION_SPEC.md`). The product is a CLI. The engine is independent of it. -- **Current state: M0 through M4 complete; M5 implemented (`D-100`); migrate v0 is implemented under `D-101`, and M6 planning under `D-102`, `D-103`, and `D-104`.** A Cargo workspace, immutable source-text primitives, parser-directed JavaScript trivia, string, numeric, punctuation, template, identifier, and regular-expression literal scanners, complete regular-expression literal validation (including Annex B), identifier classification from generated Unicode 18.0.0 tables, character reference decoding with generated WHATWG and XHTML tables, a JavaScript parser with an arena tree for complete Script and Module source text (all statements, functions, classes, and modules, with sloppy mode, Annex B, and all early errors) with explicit JSX, TypeScript, TSX, and declaration-file dialects (the complete JSX specification grammar, and the TypeScript grammar of the pinned 6.0.3 compiler with structured type nodes, declarations, and extensions), a deterministic span edit core with provenance and tests, an Astro component printer with React JSX conversion and multi-source provenance, a development-only E2 driver with three fictional fixtures and golden files (`D-048`), a development-only E1 measurement harness with generated corpora and a directory mode for an approved real-world sample kept outside the repository (`D-043`), a development-only coverage-guided fuzzer (`D-044`), and a development-only processor-time helper in Go (`D-046`) exist. Rust 1.98.0 is pinned. A standard-library-only CLI shell provides help and version output (`D-050`). Native CI and gated release and packaging workflows exist (`D-051`, `D-052`); there is no migration engine pipeline. Version 0.1.0 is published (section 13 of `docs/IMPLEMENTATION.md`). All M1 foundation contracts are implemented (through `D-061`), and the owner accepted M1 on 2026-10-19 under `D-063`. M2 is complete (`D-076`, with the merge of its last increment): read-only discovery and Git-compatible `.gitignore` matching (`D-064`, `D-065`), the first product fixtures in `fixtures/discovery/`, a read-once snapshot store with a framed snapshot identity (`D-067`), a JSON and JSONC parser (`D-068`), a full YAML 1.2 syntax parser (`D-071`), a Yarn classic lockfile parser (`D-072`), the package model: npm versions and ranges with node-semver's semantics, the `package.json` model, package manager detection, and npm, Bun, pnpm 9.0, and Yarn classic and Berry lockfiles (`D-069`, `D-070`, `D-071`, `D-072`), workspace detection with the pattern semantics of eight package manager releases (`D-073`), Next.js framework, version, configuration, and router detection (`D-074`), `frameshift inspect` with the versioned `frameshift.inspection` document (`D-075`), and adversarial inspect fixtures (`D-076`). M3 includes the CSS tokenizer and generic structural parser with bounded recovery (`D-077`, `D-078`); the Sass parser now covers statements and SassScript for explicit SCSS and indented syntax modes (`D-079`), with structured selectors (`D-081`), validated module rules (`D-082`), structured import items with checked URLs and CSS-versus-Sass classification, structured media queries, supports conditions, and import modifiers (`D-083`), and structured `@extend` targets and `@at-root` queries (`D-084`); modern CSS-style `if()` branches and conditions and optional JavaScript diagnostic recovery (`D-085`); plain CSS function passthrough with raw result declarations (`D-086`); raw URL versus SassScript-call parsing (`D-087`); raw special functions (`D-088`); numeric unit boundaries and default-export expression diagnostic recovery (`D-089`); Sass syntax conformance is established against the pinned normal parser and full external corpus (`D-090`), with explicit evaluation-dependent evidence. M3 closure and the first M4 module-resolution and import-graph APIs are recorded in `D-091` and IMPLEMENTATION.md section 37; package scopes, `imports`, self-references, and directory entry points (section 38) scopes and bindings (section 39), the App Router route tree with render contexts (section 40), and the static evaluator with experiment E3 (section 41) follow under `D-092`, the four M4 semantic blocks delivered as stacked pull requests that the agent merges after green CI. The Node10 profile and module suffixes (`D-093`, `D-094`, sections 42 and 43), the assembled source model (`D-095`, section 44), the component graph and environment classification (`D-096`, sections 45 and 46), and configuration inheritance from linked workspace packages (`D-098`, section 48) follow. M4 closed under `D-099` (section 49), and M5 began with the feature catalog and its detection (section 50); read-only assessment, accounting, route readiness, knowledge, metrics, reports, and CLI commands now exist under `D-100` (section 51). A reduced first `frameshift migrate` (migrate v0, M6-lite and M7-lite) exists under `D-101` (section 52). E1 and E2 passed (`D-047`, `D-049`), `P-002` and `P-003` are accepted, and M0 is complete; the branch-protection policy requires green CI and PR integration (`D-051`). See `docs/IMPLEMENTATION.md` for actual code and contracts; the other documents still specify the remaining product. The remote `origin` is `eiserv/frameshift` (`D-026`), default branch `main`; preparation for public visibility and AGPL licensing is accepted in `D-066`, but the owner made the repository public on 2026-10-17. Work on branches and integrate through pull requests, subject to the explicit one-run exceptions in `D-010`. +- **Current state: M0 through M4 complete; M5 implemented (`D-100`); M6 implemented (`D-102`, `D-103`, `D-104`); M7 in progress, with migrate v0 under `D-101` and its manifest, replacement policy, and structural postcondition under `D-105` to `D-107`.** A Cargo workspace, immutable source-text primitives, parser-directed JavaScript trivia, string, numeric, punctuation, template, identifier, and regular-expression literal scanners, complete regular-expression literal validation (including Annex B), identifier classification from generated Unicode 18.0.0 tables, character reference decoding with generated WHATWG and XHTML tables, a JavaScript parser with an arena tree for complete Script and Module source text (all statements, functions, classes, and modules, with sloppy mode, Annex B, and all early errors) with explicit JSX, TypeScript, TSX, and declaration-file dialects (the complete JSX specification grammar, and the TypeScript grammar of the pinned 6.0.3 compiler with structured type nodes, declarations, and extensions), a deterministic span edit core with provenance and tests, an Astro component printer with React JSX conversion and multi-source provenance, a development-only E2 driver with three fictional fixtures and golden files (`D-048`), a development-only E1 measurement harness with generated corpora and a directory mode for an approved real-world sample kept outside the repository (`D-043`), a development-only coverage-guided fuzzer (`D-044`), and a development-only processor-time helper in Go (`D-046`) exist. Rust 1.98.0 is pinned. A standard-library-only CLI shell provides help and version output (`D-050`). Native CI and gated release and packaging workflows exist (`D-051`, `D-052`); there is no migration engine pipeline. Version 0.1.0 is published (section 13 of `docs/IMPLEMENTATION.md`). All M1 foundation contracts are implemented (through `D-061`), and the owner accepted M1 on 2026-10-19 under `D-063`. M2 is complete (`D-076`, with the merge of its last increment): read-only discovery and Git-compatible `.gitignore` matching (`D-064`, `D-065`), the first product fixtures in `fixtures/discovery/`, a read-once snapshot store with a framed snapshot identity (`D-067`), a JSON and JSONC parser (`D-068`), a full YAML 1.2 syntax parser (`D-071`), a Yarn classic lockfile parser (`D-072`), the package model: npm versions and ranges with node-semver's semantics, the `package.json` model, package manager detection, and npm, Bun, pnpm 9.0, and Yarn classic and Berry lockfiles (`D-069`, `D-070`, `D-071`, `D-072`), workspace detection with the pattern semantics of eight package manager releases (`D-073`), Next.js framework, version, configuration, and router detection (`D-074`), `frameshift inspect` with the versioned `frameshift.inspection` document (`D-075`), and adversarial inspect fixtures (`D-076`). M3 includes the CSS tokenizer and generic structural parser with bounded recovery (`D-077`, `D-078`); the Sass parser now covers statements and SassScript for explicit SCSS and indented syntax modes (`D-079`), with structured selectors (`D-081`), validated module rules (`D-082`), structured import items with checked URLs and CSS-versus-Sass classification, structured media queries, supports conditions, and import modifiers (`D-083`), and structured `@extend` targets and `@at-root` queries (`D-084`); modern CSS-style `if()` branches and conditions and optional JavaScript diagnostic recovery (`D-085`); plain CSS function passthrough with raw result declarations (`D-086`); raw URL versus SassScript-call parsing (`D-087`); raw special functions (`D-088`); numeric unit boundaries and default-export expression diagnostic recovery (`D-089`); Sass syntax conformance is established against the pinned normal parser and full external corpus (`D-090`), with explicit evaluation-dependent evidence. M3 closure and the first M4 module-resolution and import-graph APIs are recorded in `D-091` and IMPLEMENTATION.md section 37; package scopes, `imports`, self-references, and directory entry points (section 38) scopes and bindings (section 39), the App Router route tree with render contexts (section 40), and the static evaluator with experiment E3 (section 41) follow under `D-092`, the four M4 semantic blocks delivered as stacked pull requests that the agent merges after green CI. The Node10 profile and module suffixes (`D-093`, `D-094`, sections 42 and 43), the assembled source model (`D-095`, section 44), the component graph and environment classification (`D-096`, sections 45 and 46), and configuration inheritance from linked workspace packages (`D-098`, section 48) follow. M4 closed under `D-099` (section 49), and M5 began with the feature catalog and its detection (section 50); read-only assessment, accounting, route readiness, knowledge, metrics, reports, and CLI commands now exist under `D-100` (section 51). A reduced first `frameshift migrate` (migrate v0, M6-lite and M7-lite) exists under `D-101` (section 52). E1 and E2 passed (`D-047`, `D-049`), `P-002` and `P-003` are accepted, and M0 is complete; the branch-protection policy requires green CI and PR integration (`D-051`). See `docs/IMPLEMENTATION.md` for actual code and contracts; the other documents still specify the remaining product. The remote `origin` is `eiserv/frameshift` (`D-026`), default branch `main`; preparation for public visibility and AGPL licensing is accepted in `D-066`, but the owner made the repository public on 2026-10-17. Work on branches and integrate through pull requests, subject to the explicit one-run exceptions in `D-010`. - **Implementation language.** Rust is the primary language for engine and CLI (`D-018`). Other languages are allowed only for specific, important tasks with a demonstrable quality advantage, a decision record, and the requirements in `docs/ENGINEERING.md` section 2.7 (`D-019`); the current uses are the development-only processor-time helper (`D-046`) and release archive helper (`D-052`), both in Go. Effort and time are not arguments against quality (`D-020`). The M0 workspace used the Rust standard library only (`D-032`). The reviewed runtime SHA-256 dependency and its exact transitive graph are recorded in `D-058` and `.github/dependencies.json`. Do not add dependencies or bypass the E1/E2 experiment gates without the required decisions. - **Documentation map.** - `README.md`: overview and status. diff --git a/README.md b/README.md index c2af777..df0d3cd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Frameshift is a framework migration engine in early development with a command-line interface. It will analyze existing software projects and migrate them from one framework to another, preserve their intended behavior, and report what it migrated, what it could not migrate, and why. -> **Status: M0 through M4 complete; M5 implemented (`D-100`).** The repository contains a buildable Rust workspace with tested immutable source-text primitives, parser-directed JavaScript scanners, complete regular-expression literal validation, ECMAScript identifier classification from pinned Unicode 18.0.0 tables, character reference decoding with pinned HTML and XHTML tables, a JavaScript parser with an arena tree for complete Script and Module source text, including sloppy mode and the Annex B web-compatibility grammar, an explicitly selected JSX dialect, and explicitly selected TypeScript, TSX, and declaration-file dialects with structured type nodes, declarations, and TypeScript extensions, a deterministic span edit core with provenance, an Astro component printer that converts React JSX and maps every output byte to its source, and a development-only harness that measures parser throughput and memory on generated JavaScript, JSX, and TypeScript corpora and on real files outside the repository, with a reproducible contract-check mode for the generated corpora, a development-only coverage-guided fuzzer for the parsers, scanners, character reference decoders, and edit core, and a development-only Go helper that measures the processor time of fuzzing runs. On an approved sample of 13,292 files from twelve open-source Next.js projects, 99.92 percent parse and every rejection is explained. The parser experiment (E1) passed. The rewriting and Astro printing experiment (E2) passed as well: three fictional Next.js fixtures are migrated deterministically, byte for byte identical on Windows and Linux, and Astro builds the results. A CLI shell now provides help and version information, including versioned JSON output (`D-050`). Native CI, cross-platform E2 hash comparison, Release Please PR automation, and deterministic archive packaging exist (`D-051`, `D-052`). M1 is complete: all foundation contracts are implemented, including deterministic collections and a checked set of resource limits (`D-060`, `D-061`), and the owner accepted the milestone on 2026-10-19 (`D-063`). The owner authorized publication of 0.1.0 on 2026-10-21. Version 0.1.0 is published: the owner merged release PR #6 on 2026-10-21, and Release run `39513431273` created the tag `v0.1.0` and the GitHub release with six archives and a checksum list. M2 is complete with its last increment (`D-076`): the engine discovers a project's files read-only, follows no links, never opens environment files, honors `.gitignore` files with Git's semantics, and reports every skipped entry and reached limit (`D-064`, `D-065`). A snapshot store reads each file at most once and identifies the whole input by a framed SHA-256 identity, and a strict JSON and JSONC parser keeps exact numbers and makes duplicate keys visible (`D-067`, `D-068`). A full YAML 1.2 syntax parser preserves document streams, tags, complex keys, anchors, and alias references without expansion (`D-071`). A package model reads `package.json`, the `packageManager` field, and npm, Bun, pnpm 9.0, and Yarn classic and Berry lockfiles through the snapshot store, detects the package manager, resolves locked versions as their package managers install them, and interprets npm version ranges with node-semver's exact semantics (`D-069`, `D-070`, `D-072`). Workspace detection evaluates `workspaces` and `pnpm-workspace.yaml` exactly as the npm, Yarn, pnpm, and Bun releases that install a project read them, checked against those package managers on tens of thousands of generated cases, and resolves a selected member through the root lockfile (`D-073`). Next.js detection identifies the framework, its version, the configuration file that Next.js loads, and the App and Pages Router directories of the selected package (`D-074`). `frameshift inspect` combines them: it identifies a project and every package of its workspace read-only, as text or as a versioned `frameshift.inspection` JSON document (`D-075`), and adversarial fixtures test it with hostile names, links, resource exhaustion, and contradictory evidence (`D-076`). M3 has started: a CSS tokenizer follows the current CSS Syntax Level 3 Editor's Draft and agrees with the public css-parsing-tests suite and with the tokenizer of Lightning CSS wherever the draft has not changed the rules (`D-077`); a generic structural parser with nesting and bounded recovery now exists (`D-078`), and a Sass parser now covers statements and SassScript in explicit SCSS and indented syntax modes (`D-079`), with exact spans and explicit unchecked-grammar evidence, and parses style rule selectors into structure with the grammars Dart Sass applies when it compiles them (`D-081`). Module rules now validate `@use` and `@forward` URLs, namespaces, and clauses without loading modules (`D-082`). Import items now distinguish CSS and Sass imports with checked URL syntax and context restrictions, `@media` and `@supports` conditions and import modifiers are parsed into structure (`D-083`), and so are `@extend` targets and `@at-root` queries (`D-084`). Modern CSS-style `if()` branches and conditions are structured as well (`D-085`). An optional JavaScript diagnostics API collects multiple errors across conservative program-item boundaries while withholding every erroneous tree (`D-085`), including supported static import/export and default-export expression boundaries in modules (`D-088`, `D-089`). Plain CSS functions now retain their raw headers, result values, and unchecked CSS grammar evidence, with callable-name disambiguation matching the reference (`D-086`). Raw URLs now follow the reference's URL grammar before falling back to SassScript calls, including escaped and vendor-prefixed names (`D-087`). Raw special functions follow decoded vendor dispatch and the shared declaration-value grammar while retaining unchecked evidence (`D-088`). Numeric units preserve subtraction boundaries and literal-versus-escaped hyphens as the reference does (`D-089`). Sass syntax conformance is established against the pinned normal parser and full external corpus (`D-090`); evaluation-dependent grammars retain explicit evidence. M3 is complete under `D-091`. M4 now provides snapshot-only local module resolution with separate TypeScript 6.0.3 bundler and Node10 profiles (`D-093`), inherited TypeScript/JavaScript path mappings, including configurations inherited from linked workspace packages (`D-098`), and a deterministic source-spanned import graph, with explicit unresolved evidence and type-only edges. Package `imports`, self-references, and local directory entry points resolve with the `import` or `require` conditions of each reference, as the pinned TypeScript resolver does (`D-092`). Scope and binding analysis resolves every name of a file to its declaration in ECMAScript's scopes and TypeScript's value, type, and namespace spaces, in agreement with the pinned TypeScript checker on generated programs (`D-092`). The Next.js adapter now derives the App Router route tree with the rules of the pinned Next.js distribution, the exports of every route convention file, and which modules run on the server or in the client bundle across `"use client"` boundaries (`D-092`). A static evaluator establishes configuration values without executing them and names every value it cannot establish; on the approved real-world sample it fully evaluates 60 percent of Next.js and 99 percent of PostCSS configurations (`D-092`, experiment E3). A component graph resolves every JSX element through bindings, imports, re-exports, and the recognized `memo`, `forwardRef`, `lazy`, and `next/dynamic` wrappers to its definition or package export, and names every value it cannot establish, and environment classification records whether each module runs during the build, in the browser, or both, with its environment-variable reads and Node.js and browser API uses (`D-096`). Next.js source facts now retain evaluated configuration, convention exports, and binding-proven direct API uses with explicit unknown/bundler evidence (`D-097`, IMPLEMENTATION.md section 47). M4 is complete, and M5 is implemented: the validated feature catalog (`D-099`) now feeds read-only assessment, counting units, route readiness, dependency evidence, exact metrics, and versioned reports (`D-100`, IMPLEMENTATION.md sections 49 to 51). Release PR automation runs with environment-secret authentication (`D-066`). The `analyze`, stored `report`, `explain`, and assessment `rules` commands exist. A reduced first `migrate` (migrate v0) is implemented under `D-101`: it converts static pages, layouts, and components into a new Astro project and preserves everything else for manual work (IMPLEMENTATION.md section 52); planning and verification commands remain planned. See [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) for existing code, contracts, limits, and development commands. Available and planned commands are distinguished below. The source model now assembles selected-package inventory, App Router facts, binding-aware JSX and environment evidence, and bounded route dependency closures with golden validation (`D-095`, IMPLEMENTATION.md section 44). +> **Status: M0 through M4 complete; M5 and M6 implemented (`D-100`, `D-102` to `D-104`); M7 in progress.** The repository contains a buildable Rust workspace with tested immutable source-text primitives, parser-directed JavaScript scanners, complete regular-expression literal validation, ECMAScript identifier classification from pinned Unicode 18.0.0 tables, character reference decoding with pinned HTML and XHTML tables, a JavaScript parser with an arena tree for complete Script and Module source text, including sloppy mode and the Annex B web-compatibility grammar, an explicitly selected JSX dialect, and explicitly selected TypeScript, TSX, and declaration-file dialects with structured type nodes, declarations, and TypeScript extensions, a deterministic span edit core with provenance, an Astro component printer that converts React JSX and maps every output byte to its source, and a development-only harness that measures parser throughput and memory on generated JavaScript, JSX, and TypeScript corpora and on real files outside the repository, with a reproducible contract-check mode for the generated corpora, a development-only coverage-guided fuzzer for the parsers, scanners, character reference decoders, and edit core, and a development-only Go helper that measures the processor time of fuzzing runs. On an approved sample of 13,292 files from twelve open-source Next.js projects, 99.92 percent parse and every rejection is explained. The parser experiment (E1) passed. The rewriting and Astro printing experiment (E2) passed as well: three fictional Next.js fixtures are migrated deterministically, byte for byte identical on Windows and Linux, and Astro builds the results. A CLI shell now provides help and version information, including versioned JSON output (`D-050`). Native CI, cross-platform E2 hash comparison, Release Please PR automation, and deterministic archive packaging exist (`D-051`, `D-052`). M1 is complete: all foundation contracts are implemented, including deterministic collections and a checked set of resource limits (`D-060`, `D-061`), and the owner accepted the milestone on 2026-10-19 (`D-063`). The owner authorized publication of 0.1.0 on 2026-10-21. Version 0.1.0 is published: the owner merged release PR #6 on 2026-10-21, and Release run `39513431273` created the tag `v0.1.0` and the GitHub release with six archives and a checksum list. M2 is complete with its last increment (`D-076`): the engine discovers a project's files read-only, follows no links, never opens environment files, honors `.gitignore` files with Git's semantics, and reports every skipped entry and reached limit (`D-064`, `D-065`). A snapshot store reads each file at most once and identifies the whole input by a framed SHA-256 identity, and a strict JSON and JSONC parser keeps exact numbers and makes duplicate keys visible (`D-067`, `D-068`). A full YAML 1.2 syntax parser preserves document streams, tags, complex keys, anchors, and alias references without expansion (`D-071`). A package model reads `package.json`, the `packageManager` field, and npm, Bun, pnpm 9.0, and Yarn classic and Berry lockfiles through the snapshot store, detects the package manager, resolves locked versions as their package managers install them, and interprets npm version ranges with node-semver's exact semantics (`D-069`, `D-070`, `D-072`). Workspace detection evaluates `workspaces` and `pnpm-workspace.yaml` exactly as the npm, Yarn, pnpm, and Bun releases that install a project read them, checked against those package managers on tens of thousands of generated cases, and resolves a selected member through the root lockfile (`D-073`). Next.js detection identifies the framework, its version, the configuration file that Next.js loads, and the App and Pages Router directories of the selected package (`D-074`). `frameshift inspect` combines them: it identifies a project and every package of its workspace read-only, as text or as a versioned `frameshift.inspection` JSON document (`D-075`), and adversarial fixtures test it with hostile names, links, resource exhaustion, and contradictory evidence (`D-076`). M3 has started: a CSS tokenizer follows the current CSS Syntax Level 3 Editor's Draft and agrees with the public css-parsing-tests suite and with the tokenizer of Lightning CSS wherever the draft has not changed the rules (`D-077`); a generic structural parser with nesting and bounded recovery now exists (`D-078`), and a Sass parser now covers statements and SassScript in explicit SCSS and indented syntax modes (`D-079`), with exact spans and explicit unchecked-grammar evidence, and parses style rule selectors into structure with the grammars Dart Sass applies when it compiles them (`D-081`). Module rules now validate `@use` and `@forward` URLs, namespaces, and clauses without loading modules (`D-082`). Import items now distinguish CSS and Sass imports with checked URL syntax and context restrictions, `@media` and `@supports` conditions and import modifiers are parsed into structure (`D-083`), and so are `@extend` targets and `@at-root` queries (`D-084`). Modern CSS-style `if()` branches and conditions are structured as well (`D-085`). An optional JavaScript diagnostics API collects multiple errors across conservative program-item boundaries while withholding every erroneous tree (`D-085`), including supported static import/export and default-export expression boundaries in modules (`D-088`, `D-089`). Plain CSS functions now retain their raw headers, result values, and unchecked CSS grammar evidence, with callable-name disambiguation matching the reference (`D-086`). Raw URLs now follow the reference's URL grammar before falling back to SassScript calls, including escaped and vendor-prefixed names (`D-087`). Raw special functions follow decoded vendor dispatch and the shared declaration-value grammar while retaining unchecked evidence (`D-088`). Numeric units preserve subtraction boundaries and literal-versus-escaped hyphens as the reference does (`D-089`). Sass syntax conformance is established against the pinned normal parser and full external corpus (`D-090`); evaluation-dependent grammars retain explicit evidence. M3 is complete under `D-091`. M4 now provides snapshot-only local module resolution with separate TypeScript 6.0.3 bundler and Node10 profiles (`D-093`), inherited TypeScript/JavaScript path mappings, including configurations inherited from linked workspace packages (`D-098`), and a deterministic source-spanned import graph, with explicit unresolved evidence and type-only edges. Package `imports`, self-references, and local directory entry points resolve with the `import` or `require` conditions of each reference, as the pinned TypeScript resolver does (`D-092`). Scope and binding analysis resolves every name of a file to its declaration in ECMAScript's scopes and TypeScript's value, type, and namespace spaces, in agreement with the pinned TypeScript checker on generated programs (`D-092`). The Next.js adapter now derives the App Router route tree with the rules of the pinned Next.js distribution, the exports of every route convention file, and which modules run on the server or in the client bundle across `"use client"` boundaries (`D-092`). A static evaluator establishes configuration values without executing them and names every value it cannot establish; on the approved real-world sample it fully evaluates 60 percent of Next.js and 99 percent of PostCSS configurations (`D-092`, experiment E3). A component graph resolves every JSX element through bindings, imports, re-exports, and the recognized `memo`, `forwardRef`, `lazy`, and `next/dynamic` wrappers to its definition or package export, and names every value it cannot establish, and environment classification records whether each module runs during the build, in the browser, or both, with its environment-variable reads and Node.js and browser API uses (`D-096`). Next.js source facts now retain evaluated configuration, convention exports, and binding-proven direct API uses with explicit unknown/bundler evidence (`D-097`, IMPLEMENTATION.md section 47). M4 is complete, and M5 is implemented: the validated feature catalog (`D-099`) now feeds read-only assessment, counting units, route readiness, dependency evidence, exact metrics, and versioned reports (`D-100`, IMPLEMENTATION.md sections 49 to 51). Release PR automation runs with environment-secret authentication (`D-066`). The `analyze`, stored `report`, `explain`, and assessment `rules` commands exist. A reduced first `migrate` (migrate v0) is implemented under `D-101`: it converts static pages, layouts, and components into a new Astro project and preserves everything else for manual work (IMPLEMENTATION.md section 52); planning and verification commands remain planned. See [docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md) for existing code, contracts, limits, and development commands. Available and planned commands are distinguished below. The source model now assembles selected-package inventory, App Router facts, binding-aware JSX and environment evidence, and bounded route dependency closures with golden validation (`D-095`, IMPLEMENTATION.md section 44). ## The problem diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae48022..6099375 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -**Status: M0 through M4 complete; M5 implemented (`D-100`).** The input layer provides read-only discovery and a read-once snapshot; the syntax layer covers JavaScript, TypeScript, JSX, TSX, declaration files, JSON/JSONC, YAML, Yarn classic, CSS structure, SCSS, and indented Sass. Sass syntax conformance is established under `D-090`, with explicit evaluation-dependent evidence. The semantic APIs provide local module resolution, import graphs, bindings, the App Router route tree and render contexts, partial static evaluation, the assembled project model with component graph, environment classification, and Next.js facts (`D-091` to `D-098`). The V1 feature catalog and the detection of its occurrences exist (`D-099`); assessment, counting ownership, route readiness, dependency knowledge, metrics, and bounded analysis reports now exist (`D-100`, IMPLEMENTATION.md section 51). The CLI implements help, version, read-only `inspect` and `analyze`, stored `report`, `explain`, and assessment `rules`. A reduced migrate v0 with path mapping, operations, manual tasks, and staged execution through the `frameshift-output` crate exists under `D-101` (IMPLEMENTATION.md section 52); the plan document, its binding, overwrite policies, the manifest, and verification remain planned. Native CI and gated release workflows exist, and version 0.1.0 is published. [IMPLEMENTATION.md](IMPLEMENTATION.md) records the existing modules and exact contracts. The remaining pipeline below is design intent unless explicitly marked implemented. Identifiers such as `D-006` or `P-003` refer to [DECISIONS.md](DECISIONS.md); only accepted entries are binding. +**Status: M0 through M4 complete; M5 and M6 implemented (`D-100`, `D-102` to `D-104`); M7 in progress.** The input layer provides read-only discovery and a read-once snapshot; the syntax layer covers JavaScript, TypeScript, JSX, TSX, declaration files, JSON/JSONC, YAML, Yarn classic, CSS structure, SCSS, and indented Sass. Sass syntax conformance is established under `D-090`, with explicit evaluation-dependent evidence. The semantic APIs provide local module resolution, import graphs, bindings, the App Router route tree and render contexts, partial static evaluation, the assembled project model with component graph, environment classification, and Next.js facts (`D-091` to `D-098`). The V1 feature catalog and the detection of its occurrences exist (`D-099`); assessment, counting ownership, route readiness, dependency knowledge, metrics, and bounded analysis reports now exist (`D-100`, IMPLEMENTATION.md section 51). The CLI implements help, version, read-only `inspect` and `analyze`, stored `report`, `explain`, and assessment `rules`. A reduced migrate v0 with path mapping, operations, manual tasks, and staged execution through the `frameshift-output` crate exists under `D-101` (IMPLEMENTATION.md section 52); the plan document, its binding, overwrite policies, the manifest, and verification remain planned. Native CI and gated release workflows exist, and version 0.1.0 is published. [IMPLEMENTATION.md](IMPLEMENTATION.md) records the existing modules and exact contracts. The remaining pipeline below is design intent unless explicitly marked implemented. Identifiers such as `D-006` or `P-003` refer to [DECISIONS.md](DECISIONS.md); only accepted entries are binding. Related documents: diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index f0520e3..c7d82ab 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Implementation Status and Source Contracts -**Status: M0 through M4 complete; M5 implemented (`D-100`); migrate v0 implemented under `D-101` (section 52).** This is the authoritative map of existing code and its current contracts. The architecture and migration documents describe the remaining product design. E1 passed on 2026-10-13 (`D-047`), and E2 passed on the same day (`D-049`, section 11), so `P-002` and `P-003` are accepted. +**Status: M0 through M4 complete; M5 and M6 implemented (`D-100`, `D-102` to `D-104`); M7 in progress, with migrate v0 under `D-101` (sections 52 to 58).** This is the authoritative map of existing code and its current contracts. The architecture and migration documents describe the remaining product design. E1 passed on 2026-10-13 (`D-047`), and E2 passed on the same day (`D-049`, section 11), so `P-002` and `P-003` are accepted. ## 1. Tooling and code map From 990b679b82b8e04dc604514aa3881be500099081 Mon Sep 17 00:00:00 2001 From: eiserv69 <156132387+eiserv69@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:10:19 +0100 Subject: [PATCH 11/11] test(migrate): cover a component with a CSS Module Co-Authored-By: Claude Opus 5.5 --- docs/IMPLEMENTATION.md | 2 +- .../migrate/static-site/expected/dry-run.txt | 6 ++-- .../expected/output/.frameshift/SUMMARY.md | 5 +-- .../expected/output/.frameshift/manifest.json | 30 +++++++++++++--- .../output/src/components/Panel.astro | 8 +++++ .../output/src/components/Panel.module.css | 7 ++++ .../output/src/pages/statements.astro | 13 ++++--- .../migrate/static-site/expected/plan.json | 35 ++++++++++++++++--- .../migrate/static-site/expected/plan.txt | 3 ++ fixtures/migrate/static-site/fixture.json | 2 +- .../source/app/statements/page.tsx | 14 +++++--- .../source/components/Panel.module.css | 7 ++++ .../static-site/source/components/Panel.tsx | 5 +++ 13 files changed, 111 insertions(+), 26 deletions(-) create mode 100644 fixtures/migrate/static-site/expected/output/src/components/Panel.astro create mode 100644 fixtures/migrate/static-site/expected/output/src/components/Panel.module.css create mode 100644 fixtures/migrate/static-site/source/components/Panel.module.css create mode 100644 fixtures/migrate/static-site/source/components/Panel.tsx diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index c7d82ab..958e159 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1949,7 +1949,7 @@ Output paths are validated as `OutputPath` values and compared without ASCII cas **CLI.** `frameshift migrate [] --to astro-preact --out [--package ] [--dry-run]` parses arguments in `crates/frameshift-cli/src/migrate_cli.rs`, calls `plan`, `check_output`, and, without `--dry-run`, `execute`. The dry run prints every operation with its kind, escaped output path, rule, and source; a real run prints counts. Exit statuses: 0, 1 when `--strict` finds a unit rated `unknown` or `unsupported`, 2 for usage errors (including every planned option), 3 for analysis input and limit failures, 4 for refused or failed output, 6 for stream failures, and 70 for internal errors. Without `--strict`, preserved files do not change the status. `--show-files` lists every operation of a real run. -**Validation.** `fixtures/migrate/` holds two fictional cases with `expected/plan.txt`, `expected/output/`, and `expected/dry-run.txt`: `static-site` (root and nested layouts, a title template, page metadata, `next/link`, a route group, a dropped segment option, an Astro component, a static Preact component, a relocated module, a global stylesheet, public assets, and a preserved dynamic route and client page) and `fail-closed` (a `src/app` project where preservation propagates from a client component and a request-time module to the pages that import them, a page with a dynamic `import()` of a project file, a page that binds the generated layout name, pages and a component whose function bindings clash with or are read by module declarations, a module that reads `import.meta`, and declaration files with a relative ambient module or a triple-slash path reference are preserved, and an optional dependency is kept); `static-site` also preserves an out-of-scope test file, rewrites type-only alias imports in a module and a declaration file, keeps a wildcard ambient declaration, and converts an async page whose body statements read a later module declaration, a component with a `module-per-render` review, an arrow page behind `export default `, a page with an expression statement, and an arrow component with a default prop value. Both expected outputs were built on 2026-11-01 with the pinned `astro7-preact10` profile outside the repository: `static-site` produced 8 pages and `fail-closed` 1 page, without a `script` tag in any page. `crates/frameshift-engine/tests/migrate.rs` plans twice and compares byte for byte, executes into a temporary directory, and checks that the written tree equals the plan, that the source is byte-identical afterwards, that nothing else was created, and that unsafe outputs are refused. `crates/frameshift-cli/tests/migrate.rs` checks the dry-run text, the written tree, and exit statuses. Goldens change only through: +**Validation.** `fixtures/migrate/` holds two fictional cases with `expected/plan.txt`, `expected/output/`, and `expected/dry-run.txt`: `static-site` (root and nested layouts, a title template, page metadata, `next/link`, a route group, a dropped segment option, an Astro component, a static Preact component, a relocated module, a global stylesheet, public assets, and a preserved dynamic route and client page) and `fail-closed` (a `src/app` project where preservation propagates from a client component and a request-time module to the pages that import them, a page with a dynamic `import()` of a project file, a page that binds the generated layout name, pages and a component whose function bindings clash with or are read by module declarations, a module that reads `import.meta`, and declaration files with a relative ambient module or a triple-slash path reference are preserved, and an optional dependency is kept); `static-site` also preserves an out-of-scope test file, rewrites type-only alias imports in a module and a declaration file, keeps a wildcard ambient declaration, and converts an async page whose body statements read a later module declaration, a component with a `module-per-render` review, an arrow page behind `export default `, a page with an expression statement, an arrow component with a default prop value, and a component with a CSS Module. Both expected outputs were built on 2026-11-01 with the pinned `astro7-preact10` profile outside the repository: `static-site` produced 8 pages and `fail-closed` 1 page, without a `script` tag in any page. `crates/frameshift-engine/tests/migrate.rs` plans twice and compares byte for byte, executes into a temporary directory, and checks that the written tree equals the plan, that the source is byte-identical afterwards, that nothing else was created, and that unsafe outputs are refused. `crates/frameshift-cli/tests/migrate.rs` checks the dry-run text, the written tree, and exit statuses. Goldens change only through: ```sh cargo test -p frameshift-engine --offline --locked --test migrate -- --ignored update_migrate_goldens diff --git a/fixtures/migrate/static-site/expected/dry-run.txt b/fixtures/migrate/static-site/expected/dry-run.txt index e1989a0..62d4326 100644 --- a/fixtures/migrate/static-site/expected/dry-run.txt +++ b/fixtures/migrate/static-site/expected/dry-run.txt @@ -11,6 +11,8 @@ copy public/paper.svg asset.verbatim from public/paper.svg copy public/robots.txt asset.verbatim from public/robots.txt transform src/components/Badge.tsx component.preact-static from components/Badge.tsx create src/components/Card.astro component.astro from components/Card.tsx review +create src/components/Panel.astro component.astro from components/Panel.tsx review +copy src/components/Panel.module.css style.relocate from components/Panel.module.css create src/components/SiteHeader.astro component.astro from components/SiteHeader.tsx review create src/components/Stamp.astro component.astro from components/Stamp.tsx review create src/layouts/DocsLayout.astro layout.chain from app/docs/layout.tsx @@ -29,5 +31,5 @@ copy src/styles/globals.css style.relocate from app/globals.css transform src/types/api.d.ts module.relocate from types/api.d.ts transform src/types/assets.d.ts module.relocate from types/assets.d.ts config tsconfig.json config.tsconfig -30 operations planned (13 converted to Astro, 3 preserved); 0 files written. -Manual tasks: 11. See .frameshift/SUMMARY.md in the output directory. +32 operations planned (14 converted to Astro, 3 preserved); 0 files written. +Manual tasks: 12. See .frameshift/SUMMARY.md in the output directory. diff --git a/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md b/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md index 5a675f3..e517926 100644 --- a/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md +++ b/fixtures/migrate/static-site/expected/output/.frameshift/SUMMARY.md @@ -6,9 +6,9 @@ Migrate v0 converts only static pages, layouts, and components that the analysis ## Results -- Converted to Astro components: 13 +- Converted to Astro components: 14 - Kept as JavaScript or TypeScript with rewritten imports: 5 -- Copied unchanged: 3 +- Copied unchanged: 4 - Generated project files: 5 - Preserved for manual migration: 3 - Not copied: 3 @@ -30,6 +30,7 @@ Migrate v0 converts only static pages, layouts, and components that the analysis - `app/changelog/page.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) - `app/page.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) - `components/Card.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization) +- `components/Panel.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization) - `components/SiteHeader.tsx`: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) - `components/Stamp.tsx`: converted; check the result, because the conversion relies on assumptions that a review must confirm (module-per-render) diff --git a/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json b/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json index 8cb79ab..d15a2b7 100644 --- a/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json +++ b/fixtures/migrate/static-site/expected/output/.frameshift/manifest.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "planHash": "@PLAN_HASH@", "binding": { - "snapshot": "sha256:392a22ad93d5e9a2d85f8c0859ed816dcc7134bf2562e2930a4eaba5ca08f40c", + "snapshot": "sha256:ecb45733676a5af1501a421af68d2627880b7e636061c66269a19e19b9248484", "engineVersion": "@ENGINE_VERSION@", "ruleset": "nextjs-to-astro-preact/assessment-v1", "knowledgeBases": [ @@ -27,8 +27,8 @@ "ruleVersion": 1, "source": null, "review": false, - "size": 2514, - "sha256": "sha256:5b96fddcbf6a7c4b4cde7c833dc415ca301ef0e3210451c96c378260a1e797ea" + "size": 2707, + "sha256": "sha256:37f6396e319d029df2a62bc14e61f79a2ff86a745be86d9f22e062c292165dae" }, { "kind": "preserve", @@ -130,6 +130,26 @@ "size": 140, "sha256": "sha256:9decbb8f327c8f1fe90a3982f45a463b0a27125541cb6eaf2608eda4673c2cd3" }, + { + "kind": "create", + "output": "src/components/Panel.astro", + "rule": "component.astro", + "ruleVersion": 1, + "source": "components/Panel.tsx", + "review": true, + "size": 137, + "sha256": "sha256:8be4bc314d627aa8c7be113986a4199856ad05ae5591237a3a8d5943c3e3f71a" + }, + { + "kind": "copy", + "output": "src/components/Panel.module.css", + "rule": "style.relocate", + "ruleVersion": 1, + "source": "components/Panel.module.css", + "review": false, + "size": 69, + "sha256": "sha256:e3acbf3d4371bb2224af544ca187c09a5de056759bcca4eee781e611d0a229f5" + }, { "kind": "create", "output": "src/components/SiteHeader.astro", @@ -267,8 +287,8 @@ "ruleVersion": 1, "source": "app/statements/page.tsx", "review": false, - "size": 367, - "sha256": "sha256:4b4f039977c24238866718114a89e38267ee054d2399cf987193d5b835ff4934" + "size": 445, + "sha256": "sha256:6990ccfe4cbc33314638ec3fed8a4ac83af8fad153bb208455ca7b6ae6b37f98" }, { "kind": "copy", diff --git a/fixtures/migrate/static-site/expected/output/src/components/Panel.astro b/fixtures/migrate/static-site/expected/output/src/components/Panel.astro new file mode 100644 index 0000000..86f03e5 --- /dev/null +++ b/fixtures/migrate/static-site/expected/output/src/components/Panel.astro @@ -0,0 +1,8 @@ +--- +import styles from './Panel.module.css'; + +const { children } = Astro.props; +--- +
+ +
diff --git a/fixtures/migrate/static-site/expected/output/src/components/Panel.module.css b/fixtures/migrate/static-site/expected/output/src/components/Panel.module.css new file mode 100644 index 0000000..851aac7 --- /dev/null +++ b/fixtures/migrate/static-site/expected/output/src/components/Panel.module.css @@ -0,0 +1,7 @@ +.panel { + padding: 1rem; +} + +.panel :global(a) { + color: inherit; +} diff --git a/fixtures/migrate/static-site/expected/output/src/pages/statements.astro b/fixtures/migrate/static-site/expected/output/src/pages/statements.astro index 4ff9bd0..278e4fd 100644 --- a/fixtures/migrate/static-site/expected/output/src/pages/statements.astro +++ b/fixtures/migrate/static-site/expected/output/src/pages/statements.astro @@ -1,4 +1,5 @@ --- +import Panel from '../components/Panel.astro'; import RootLayout from '../layouts/RootLayout.astro'; const items = ['first', 'second']; @@ -6,9 +7,11 @@ const items = ['first', 'second']; items.reverse(); --- -
    - {items.map((item) => ( -
  • {`${item}`}
  • - ))} -
+ +
    + {items.map((item) => ( +
  • {`${item}`}
  • + ))} +
+
diff --git a/fixtures/migrate/static-site/expected/plan.json b/fixtures/migrate/static-site/expected/plan.json index 9be58a0..ed52104 100644 --- a/fixtures/migrate/static-site/expected/plan.json +++ b/fixtures/migrate/static-site/expected/plan.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "planHash": "@PLAN_HASH@", "binding": { - "snapshot": "sha256:392a22ad93d5e9a2d85f8c0859ed816dcc7134bf2562e2930a4eaba5ca08f40c", + "snapshot": "sha256:ecb45733676a5af1501a421af68d2627880b7e636061c66269a19e19b9248484", "engineVersion": "@ENGINE_VERSION@", "ruleset": "nextjs-to-astro-preact/assessment-v1", "knowledgeBases": [ @@ -27,8 +27,8 @@ "ruleVersion": 1, "source": null, "review": false, - "size": 2514, - "sha256": "sha256:5b96fddcbf6a7c4b4cde7c833dc415ca301ef0e3210451c96c378260a1e797ea" + "size": 2707, + "sha256": "sha256:37f6396e319d029df2a62bc14e61f79a2ff86a745be86d9f22e062c292165dae" }, { "kind": "preserve", @@ -130,6 +130,26 @@ "size": 140, "sha256": "sha256:9decbb8f327c8f1fe90a3982f45a463b0a27125541cb6eaf2608eda4673c2cd3" }, + { + "kind": "create", + "output": "src/components/Panel.astro", + "rule": "component.astro", + "ruleVersion": 1, + "source": "components/Panel.tsx", + "review": true, + "size": 137, + "sha256": "sha256:8be4bc314d627aa8c7be113986a4199856ad05ae5591237a3a8d5943c3e3f71a" + }, + { + "kind": "copy", + "output": "src/components/Panel.module.css", + "rule": "style.relocate", + "ruleVersion": 1, + "source": "components/Panel.module.css", + "review": false, + "size": 69, + "sha256": "sha256:e3acbf3d4371bb2224af544ca187c09a5de056759bcca4eee781e611d0a229f5" + }, { "kind": "create", "output": "src/components/SiteHeader.astro", @@ -267,8 +287,8 @@ "ruleVersion": 1, "source": "app/statements/page.tsx", "review": false, - "size": 367, - "sha256": "sha256:4b4f039977c24238866718114a89e38267ee054d2399cf987193d5b835ff4934" + "size": 445, + "sha256": "sha256:6990ccfe4cbc33314638ec3fed8a4ac83af8fad153bb208455ca7b6ae6b37f98" }, { "kind": "copy", @@ -357,6 +377,11 @@ "source": "components/Card.tsx", "text": "converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization)" }, + { + "kind": "review", + "source": "components/Panel.tsx", + "text": "converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization)" + }, { "kind": "review", "source": "components/SiteHeader.tsx", diff --git a/fixtures/migrate/static-site/expected/plan.txt b/fixtures/migrate/static-site/expected/plan.txt index cae15c2..2f045e3 100644 --- a/fixtures/migrate/static-site/expected/plan.txt +++ b/fixtures/migrate/static-site/expected/plan.txt @@ -10,6 +10,8 @@ copy public/paper.svg asset.verbatim public/paper.svg copy public/robots.txt asset.verbatim public/robots.txt transform src/components/Badge.tsx component.preact-static components/Badge.tsx create src/components/Card.astro component.astro components/Card.tsx review +create src/components/Panel.astro component.astro components/Panel.tsx review +copy src/components/Panel.module.css style.relocate components/Panel.module.css create src/components/SiteHeader.astro component.astro components/SiteHeader.tsx review create src/components/Stamp.astro component.astro components/Stamp.tsx review create src/layouts/DocsLayout.astro layout.chain app/docs/layout.tsx @@ -38,6 +40,7 @@ task Preserved tests/site.test.ts: preserved for manual migration: tests and rep task Review app/changelog/page.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) task Review app/page.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) task Review components/Card.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization) +task Review components/Panel.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (attribute-value-serialization) task Review components/SiteHeader.tsx: converted; check the result, because the analysis rates it review, and the conversion relies on assumptions that a review must confirm (child-not-true) task Review components/Stamp.tsx: converted; check the result, because the conversion relies on assumptions that a review must confirm (module-per-render) task NotCopied next.config.mjs: replaced by the generated astro.config.mjs diff --git a/fixtures/migrate/static-site/fixture.json b/fixtures/migrate/static-site/fixture.json index 4734932..e27b78e 100644 --- a/fixtures/migrate/static-site/fixture.json +++ b/fixtures/migrate/static-site/fixture.json @@ -1,7 +1,7 @@ { "$schema": "../../../schemas/fixture.schema.json", "kind": "migrate", - "description": "A fictional static App Router site for migrate v0 (D-101): a root layout with a title template, description, global stylesheet, and an Astro-convertible header component; static pages with next/link, page metadata, a dropped segment option, a route group, a nested layout, a static Preact component with an early return, an Astro component whose module declaration evaluates a call and so needs review, an arrow page behind a default export name, a page with an expression statement, an arrow component with a default prop value, a relocated library module and declaration file whose type-only aliased imports are rewritten, a wildcard ambient declaration, an async page with body statements that read a later module binding, and public assets. A test file is preserved as out of scope. A dynamic route without generateStaticParams and a client page are preserved. The manifest names the real next package, so the harness creates it at test time (Q-014).", + "description": "A fictional static App Router site for migrate v0 (D-101): a root layout with a title template, description, global stylesheet, and an Astro-convertible header component; static pages with next/link, page metadata, a dropped segment option, a route group, a nested layout, a static Preact component with an early return, an Astro component whose module declaration evaluates a call and so needs review, an arrow page behind a default export name, a page with an expression statement, an arrow component with a default prop value, a component with a CSS Module, a relocated library module and declaration file whose type-only aliased imports are rewritten, a wildcard ambient declaration, an async page with body statements that read a later module binding, and public assets. A test file is preserved as out of scope. A dynamic route without generateStaticParams and a client page are preserved. The manifest names the real next package, so the harness creates it at test time (Q-014).", "createdAtTestTime": [ { "kind": "file", diff --git a/fixtures/migrate/static-site/source/app/statements/page.tsx b/fixtures/migrate/static-site/source/app/statements/page.tsx index a05821d..890b19f 100644 --- a/fixtures/migrate/static-site/source/app/statements/page.tsx +++ b/fixtures/migrate/static-site/source/app/statements/page.tsx @@ -1,3 +1,5 @@ +import { Panel } from '@/components/Panel'; + export const metadata = { title: 'Statements' }; export default function StatementsPage() { @@ -5,10 +7,12 @@ export default function StatementsPage() { // An expression statement keeps its place before the markup. items.reverse(); return ( -
    - {items.map((item) => ( -
  • {`${item}`}
  • - ))} -
+ +
    + {items.map((item) => ( +
  • {`${item}`}
  • + ))} +
+
); } diff --git a/fixtures/migrate/static-site/source/components/Panel.module.css b/fixtures/migrate/static-site/source/components/Panel.module.css new file mode 100644 index 0000000..851aac7 --- /dev/null +++ b/fixtures/migrate/static-site/source/components/Panel.module.css @@ -0,0 +1,7 @@ +.panel { + padding: 1rem; +} + +.panel :global(a) { + color: inherit; +} diff --git a/fixtures/migrate/static-site/source/components/Panel.tsx b/fixtures/migrate/static-site/source/components/Panel.tsx new file mode 100644 index 0000000..69fc4e9 --- /dev/null +++ b/fixtures/migrate/static-site/source/components/Panel.tsx @@ -0,0 +1,5 @@ +import styles from './Panel.module.css'; + +export function Panel({ children }: { children: React.ReactNode }) { + return
{children}
; +}