Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions crates/frameshift-cli/src/analysis_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use frameshift_engine::analysis::{
use frameshift_engine::config::PackageChoice;
use frameshift_engine::foundation::{PathLimits, SourcePath};
use frameshift_engine::limits::Limits;
use frameshift_engine::migrate;
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::io::{self, Write};
Expand Down Expand Up @@ -379,6 +380,20 @@ pub(crate) fn run(
feature.v1
)?;
}
// Transformation rules exist for what migrates, not for what is unsupported.
if !unsupported {
for rule in migrate::rules::RULES {
writeln!(
stdout,
"{}/{} v{} {} {}",
frameshift_engine::catalog::PATH,
rule.id,
rule.version,
rule.kind.as_str(),
rule.summary
)?;
}
}
Ok(0)
}
Request::Analyze(options) => {
Expand Down Expand Up @@ -550,6 +565,20 @@ fn explain(id: &str, stdout: &mut impl Write, stderr: &mut impl Write) -> io::Re
writeln!(stdout, "{id} {message}\n\n{explanation}\n\n{remediation}")?;
return Ok(0);
}
let transformation = id
.strip_prefix(frameshift_engine::catalog::PATH)
.and_then(|rest| rest.strip_prefix('/'))
.and_then(migrate::rules::find);
if let Some(rule) = transformation {
writeln!(
stdout,
"{id} version {}, operation {}\n\n{}",
rule.version,
rule.kind.as_str(),
rule.summary
)?;
return Ok(0);
}
let feature = if let Some(name) = id.strip_prefix("nextjs-to-astro-preact/assessment.") {
frameshift_engine::catalog::FEATURES.iter().find(|f| {
matches!(f.v1, frameshift_engine::catalog::V1::Assessed(_))
Expand Down
6 changes: 5 additions & 1 deletion crates/frameshift-cli/tests/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ fn assessment_rule_metadata_excludes_out_of_scope_features() {
.map(|l| l.split_whitespace().next().unwrap())
.collect();
let expected: Vec<_> = FEATURES.iter().filter(|f| matches!(f.v1, V1::Assessed(statuses) if !unsupported_only || statuses.contains(&Status::Unsupported)))
.map(|f| format!("{PATH}/assessment.{}", f.id.replace('.', "-"))).collect();
.map(|f| format!("{PATH}/assessment.{}", f.id.replace('.', "-")))
.chain(frameshift_engine::migrate::rules::RULES.iter().filter(|_| !unsupported_only).map(|r| format!("{PATH}/{}", r.id)))
.collect();
assert_eq!(actual, expected);
}
for feature in FEATURES.iter().filter(|f| f.v1 == V1::OutOfScope) {
Expand Down Expand Up @@ -387,6 +389,8 @@ fn explanations_and_help_are_available_without_a_source_project() {
"explain",
"nextjs-to-astro-preact/assessment.nextjs-api-request",
],
vec!["explain", "nextjs-to-astro-preact/route.static-page"],
vec!["rules", "show", "nextjs-to-astro-preact/layout.chain"],
vec!["rules"],
vec!["help", "analyze"],
vec!["analyze", "--help"],
Expand Down
25 changes: 25 additions & 0 deletions crates/frameshift-engine/src/migrate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

mod convert;
mod plan_document;
pub mod rules;
mod scaffold;

use std::collections::{BTreeMap, BTreeSet};
Expand Down Expand Up @@ -1099,6 +1100,7 @@ impl<'a> Planner<'a> {
"two operations write one output path",
));
}
check_rules(&operations)?;
let snapshot = self.snapshot().identity().map_err(|error| MigrateError {
kind: MigrateErrorKind::Input,
message: error.to_string(),
Expand All @@ -1123,6 +1125,29 @@ impl<'a> Planner<'a> {
}
}

// Every operation names a registered rule of its own kind, and rules are
// exclusive: one source file reaches at most one of them (FS4001 fails closed).
fn check_rules(operations: &[Operation]) -> Result<(), MigrateError> {
let mut sources = BTreeSet::new();
for operation in operations {
if rules::find(operation.rule).is_none_or(|rule| rule.kind != operation.kind) {
return Err(MigrateError::internal(
"an operation names an unregistered rule",
));
}
if operation
.source
.as_ref()
.is_some_and(|source| !sources.insert(source))
{
return Err(MigrateError::internal(
"two rules produced output for one source file",
));
}
}
Ok(())
}

// Why a page is outside v0 before any conversion is attempted.
fn page_refusal(route: &AppRoute) -> Option<&'static str> {
if route.segments.iter().any(|s| {
Expand Down
7 changes: 5 additions & 2 deletions crates/frameshift-engine/src/migrate/plan_document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use frameshift_core::{Sha256Digest, Sha256Hasher, SourcePath};
use frameshift_input::SnapshotId;

use super::{ManualTask, MigrateError, MigrateErrorKind, Operation};
use super::{ManualTask, MigrateError, MigrateErrorKind, Operation, rules};
use crate::document::{self, Value as V};
use crate::limits::Limits;

Expand Down Expand Up @@ -64,10 +64,13 @@ pub(super) fn print(
let mut entries = Vec::with_capacity(operations.len());
for operation in operations {
let digest = Sha256Digest::hash(operation.bytes(), limits.hash).map_err(|_| too_large())?;
let rule = rules::find(operation.rule)
.ok_or_else(|| MigrateError::internal("an operation names an unregistered rule"))?;
entries.push(V::Object(vec![
("kind", V::string(operation.kind.as_str())),
("output", V::string(operation.output.as_str())),
("rule", V::string(operation.rule)),
("rule", V::string(rule.id)),
("ruleVersion", V::Number(rule.version)),
("source", optional_path(operation.source.as_ref())),
("review", V::Bool(operation.review)),
("size", V::count(operation.bytes().len())),
Expand Down
110 changes: 110 additions & 0 deletions crates/frameshift-engine/src/migrate/rules.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! The compile-time registry of migrate v0's transformation rules (`D-104`).
//!
//! A rule's version increases whenever its output can change. Planning checks
//! every operation against this registry, so an operation can only name a
//! registered rule of its own kind, and one source file can only reach one
//! rule (ARCHITECTURE.md section 12.2).

use super::OperationKind;

/// One transformation rule.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rule {
/// The identifier below the catalog's migration path, such as
/// `route.static-page`.
pub id: &'static str,
/// Increases whenever the rule's output can change.
pub version: u64,
/// The operation that the rule produces.
pub kind: OperationKind,
/// One sentence that says what the rule does.
pub summary: &'static str,
}

const fn rule(id: &'static str, kind: OperationKind, summary: &'static str) -> Rule {
Rule {
id,
version: 1,
kind,
summary,
}
}

/// Every registered rule, sorted by identifier.
pub const RULES: &[Rule] = &[
rule(
"asset.verbatim",
OperationKind::Copy,
"Copies a public asset or non-code data file unchanged.",
),
rule(
"component.astro",
OperationKind::Create,
"Converts a server component into an Astro component.",
),
rule(
"component.preact-static",
OperationKind::Transform,
"Keeps a component as a script for Preact and rewrites its imports.",
),
rule(
"config.astro",
OperationKind::Config,
"Generates astro.config.mjs with the Preact integration.",
),
rule(
"config.gitignore",
OperationKind::Config,
"Keeps the source .gitignore entries and adds Astro's build directories.",
),
rule(
"config.package-manifest",
OperationKind::Config,
"Generates package.json without Next.js and React, with the pinned target profile.",
),
rule(
"config.tsconfig",
OperationKind::Config,
"Generates tsconfig.json with Astro's strict settings and Preact's JSX settings.",
),
rule(
"layout.chain",
OperationKind::Create,
"Converts a layout into an Astro layout that wraps its parent.",
),
rule(
"metadata.favicon",
OperationKind::Copy,
"Copies the root favicon to public/.",
),
rule(
"module.relocate",
OperationKind::Transform,
"Moves a module or declaration file and rewrites its local specifiers.",
),
rule(
"preserve.unmigrated",
OperationKind::Preserve,
"Copies an unmigrated file below .frameshift/unmigrated/ and adds a manual task.",
),
rule(
"report.summary",
OperationKind::Config,
"Generates .frameshift/SUMMARY.md with the results and manual tasks.",
),
rule(
"route.static-page",
OperationKind::Create,
"Converts a static page into an Astro page inside its nearest layout.",
),
rule(
"style.relocate",
OperationKind::Copy,
"Moves a stylesheet whose references stay valid at its new location.",
),
];

/// Looks up a rule by its identifier below the catalog's migration path.
pub fn find(id: &str) -> Option<&'static Rule> {
RULES.iter().find(|rule| rule.id == id)
}
15 changes: 15 additions & 0 deletions crates/frameshift-engine/tests/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use frameshift_engine::foundation::{IdentifierLimits, RuleId};
use frameshift_engine::migrate::{self, MigrateErrorKind, MigrateOptions, Plan};
use support::Json;

Expand Down Expand Up @@ -247,6 +248,20 @@ fn execution_writes_exactly_the_plan_and_never_touches_the_source() {
}
}

#[test]
fn the_rule_registry_is_sorted_unique_and_catalog_spelled() {
let rules = migrate::rules::RULES;
assert!(rules.windows(2).all(|pair| pair[0].id < pair[1].id));
for rule in rules {
let id = format!("{}/{}", frameshift_engine::catalog::PATH, rule.id);
RuleId::new(&id, IdentifierLimits::default()).unwrap_or_else(|_| panic!("{id}"));
assert!(rule.version >= 1, "{id}");
assert!(rule.summary.ends_with('.'), "{id}");
assert_eq!(migrate::rules::find(rule.id), Some(rule));
}
assert_eq!(migrate::rules::find("route.unknown"), None);
}

#[test]
fn plan_hashes_bind_the_source_and_the_selected_package() {
let case = cases().into_iter().next().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ frameshift rules [--to <target>] [--unsupported]
frameshift rules show <rule-id>
```

**Implemented for assessment metadata (`D-100`).** Lists only `V1::Assessed` feature predicates with their version, feature, and allowed catalog statuses. Out-of-scope features have no assessment rule: their invented full rule IDs are rejected, while `explain <feature-id>` describes the scope exclusion. These predicates assess source; they emit no operations and do not count as transformation rule coverage. Transformation-rule listing and the detailed transformation examples below remain planned. `--unsupported` lists features that are detected but not migratable in the current stage. `show` prints the assessment predicate's catalog statuses, version, specification, and evidence boundary; detailed transformation examples and verification requirements remain planned.
**Implemented for assessment metadata (`D-100`).** Lists only `V1::Assessed` feature predicates with their version, feature, and allowed catalog statuses. Out-of-scope features have no assessment rule: their invented full rule IDs are rejected, while `explain <feature-id>` describes the scope exclusion. These predicates assess source; they emit no operations and do not count as transformation rule coverage. Transformation rules are listed after them with their version, operation, and summary (`D-104`), and `explain` and `rules show` accept their full identifiers. The detailed transformation examples below remain planned. `--unsupported` lists features that are detected but not migratable in the current stage. `show` prints the assessment predicate's catalog statuses, version, specification, and evidence boundary; detailed transformation examples and verification requirements remain planned.

```text
$ frameshift rules --to astro-preact
Expand Down
9 changes: 9 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Statuses change through an edit to this file under the integration workflow in D
| D-101 | Migrate v0: a reduced first `migrate` for static App Router sites | Accepted |
| 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 |
| 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 |
Expand Down Expand Up @@ -1200,6 +1201,14 @@ Statuses change through an edit to this file under the integration workflow in D
- **Binding:** The plan document records the effective `to` and `package`. A file in the source root is also covered by the snapshot identity, while a file outside it affects a plan only through those options.
- **Revisit if:** A real project needs `exclude`.

### D-104: The rule registry of migrate

- **Status:** Accepted (2026-11-01), under the owner's instruction to finish M6 first. It closes M6 together with `D-102` and `D-103`.
- **Decision:** `frameshift_engine::migrate::rules::RULES` is a compile-time, sorted list of rule identifiers below the migration path, each with a version, an operation kind, and a summary. Planning checks every operation against it: an unregistered rule or a rule of another kind is an internal error, and so are two operations for one source file, because a source file must reach exactly one rule. Each plan operation records `ruleVersion`, so a rule change makes older plans stale. `frameshift rules` lists the registered rules after the assessment predicates, and `explain` and `rules show` accept their full identifiers.
- **Rationale:** Rule selection in migrate v0 is one classification function, so exclusivity holds by construction and the plan-time check is its fail-closed proof. The declarative preconditions and the feature ID index of ARCHITECTURE.md section 12 add nothing until several rules can match one occurrence, so they stay planned and are not simulated by metadata without an effect.
- **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.

## Technique proposals and related accepted decisions


Expand Down
8 changes: 7 additions & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1968,7 +1968,7 @@ The hash is SHA-256 of the domain line `frameshift.plan.v1` and the printed docu

**Validation.** `crates/frameshift-engine/tests/migrate.rs` compares `expected/plan.json` with each fixture's document, with `@ENGINE_VERSION@` and `@PLAN_HASH@` in place of the release-dependent values, and checks that a file no operation copies changes the snapshot identity and the hash. `crates/frameshift-cli/tests/migrate.rs` checks that `--out` and `--format json` agree, that an existing destination and a destination inside the source are refused, that a tampered, missing, or stale document exits with 3 and writes nothing, and that the matching document produces the golden tree.

**Boundaries.** The rule registry of M6 is outstanding, so rule versions are absent; `binding.options` holds the effective `to` and `package`. Later commands do not read a stored plan for anything except this comparison.
**Boundaries.** `binding.options` holds the effective `to` and `package`, and operations record their rule version (section 55). Later commands do not read a stored plan for anything except this comparison.

## 54. Project configuration (`D-103`)

Expand All @@ -1979,3 +1979,9 @@ The hash is SHA-256 of the domain line `frameshift.plan.v1` and the printed docu
**Validation.** `crates/frameshift-engine/tests/config.rs` covers the key set, comments, rejected keys and values without echoing them, and the admitted-file rule. `crates/frameshift-cli/tests/migrate.rs` covers precedence, `--config`, `--no-config`, and that the file stays out of the output.

**Boundaries.** There is no `exclude` or `envNames` key (`D-103`). The planned `--env-names` option remains a usage error.

## 55. Rule registry (`D-104`)

`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.
Loading
Loading