Files
Paolo Mottadelli 1ab6833f1e fix(stardust): make migrate output genuinely zip-and-deploy portable (#133)
* fix(stardust): make migrate output genuinely zip-and-deploy portable

The 0.7.0 contract claimed "self-contained, zip-and-deploy" but
emitted root-absolute paths (/assets/..., /beers/) plus
directory-only nav links. Those work on a webserver at the host
root only — they 404 on file:// and at any subpath. This PR
delivers what the claim promised:

- Output path is URL-literal: /beers/ -> beers/index.html,
  /about-us/history.html -> about-us/history.html. No more
  synthesizing /about/index.html from a slug.
- Page map (state.json.migrate.pageMap[]) built once per run;
  per-page rendering and internal-link rewriting both consume it.
- Reference shape is depth-aware relative: prefix is ./ for
  depth-0 pages, ../ * depth otherwise. Works on file://, at
  host root, and at any subpath without rewriting.
- Phase 3 broadens the self-containment check into a full
  portability audit: refuses absolute internal refs, refuses
  directory-only nav, runs the new pageMap + file:// fixtures.
- New fixtures: file-protocol-audit.mjs (Playwright file://
  round-trip) and pagemap-audit.mjs (static pageMap consistency).
- asset-bundling.md and migrate-output-format.md aligned to the
  new depth-aware-relative shape; state.json contract gains
  pageMap[].

Plugin bumped 0.7.0 -> 0.7.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* review: fix four flagged correctness issues

- Directory-only-nav regex now catches `./beers/` (the cited example).
  Old pattern required a-z as first char; `./beers/` starts with `.`
  and would have slipped through. New pattern accepts ./, ../, /, or a
  bare segment, and excludes scheme-bearing URLs (no false positive
  on https://google.com/).
- Add a second grep for absolute url() references inside inline style
  attrs and <style> blocks (and CSS files); the original href/src grep
  missed these.
- SKILL.md Outputs row no longer claims the sidecar lives at
  <source-url-path>/_meta.json (nonsense for .html-leaf pages).
  Replaced with a row that points at the canonical sidecar rule.
- migration-procedure.md § Page map now explicitly says same-origin
  absolute URLs are stripped to their path portion before pageMap
  lookup (per content-preservation.md § Internal link rewriting),
  closing the loophole that would let <a href="https://origin/foo/">
  pass through verbatim.

Regex patterns verified against a test fixture before commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* review: update migrate-self-contained-bundle eval to 0.7.1 contract

The eval was pinned to 0.7.0's root-relative /assets/<subpath>
shape. This PR's contract change to depth-aware relative paths
would have made the eval fail against correct 0.7.1 output.

- detection_src_href criterion now asserts <prefix>assets/<subpath>
  (not /assets/<subpath>).
- Renamed prefix_resolution_root_relative -> prefix_normalises_authoring;
  description flipped from "reference is left as-is" to "authoring
  is normalised to the depth-aware emit form".
- Added 4 new criteria: depth_aware_paths (6pts),
  pagemap_constructed (6pts), internal_links_via_pagemap (5pts),
  portability_audit_passes (13pts).
- Setup uses real source URLs that exercise the URL-literal rule:
  /, /beers/, /about/history.html (preserve .html leaf),
  /docs/api/ (depth-2 nested).
- Prototype HTML now carries internal nav in three authoring styles
  (root-absolute, same-origin absolute, directory-only) plus a
  broken-link case, exercising the page map rewrite.
- Run 1 expected behavior rewritten to expect depth-aware emission
  ('./assets/...'), pageMap built before render, broken-link
  flagging, and the 6-step portability audit passing.
- Run 4 (cross-page dedup) updated so the second page emits its
  shared asset with its own '../' prefix while the underlying file
  on disk is a single copy.
- Weights re-balanced to sum to 100; portability_audit_passes gets
  the largest weight (13pts) since it backs the contract claim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:29:23 -07:00

30 lines
7.3 KiB
JSON

{
"name": "migrate-self-contained-bundle",
"total": 100,
"criteria": [
{ "id": "activated", "weight": 3, "description": "The stardust:migrate skill was invoked." },
{ "id": "no_current_escapes", "weight": 6, "description": "After run 1, no HTML file under stardust/migrated/ contains the substring '../current/'. Self-containment verified by absence of escape segments. (Acceptance criterion #1.)" },
{ "id": "zip_deploy_ready", "weight": 5, "description": "Running 'cd stardust/migrated && zip -r out.zip .' produces a zip that renders identically in three contexts: extracted and served at the host root, extracted and served at any subpath, AND opened directly via file://. No 404s on any bundled asset. (Acceptance criteria #2, #3 + the new file:// portability guarantee.)" },
{ "id": "idempotent_modulo_timestamp", "weight": 5, "description": "Re-running migrate without source changes produces output that differs only in the migrate-provenance timestamp line. With --pin-timestamp, output is byte-identical to the prior run. (Acceptance criterion #4.)" },
{ "id": "asset_count_in_report", "weight": 3, "description": "Run summary includes 'N pages, M bundled assets, KKK KB HTML total — self-contained, zip-and-deploy' so the user sees the bundle composition at a glance. (Acceptance criterion #5.)" },
{ "id": "missing_asset_surfaced", "weight": 4, "description": "The 'generated/missing.jpg' reference surfaces in the run report's Missing assets block AND in state.json.migrate.missingAssets[]. Migrate completes successfully (warnings, not errors). (Acceptance criterion #6.)" },
{ "id": "detection_src_href", "weight": 3, "description": "Detection shape #1 (single-URL src/href): the <link rel=\"icon\"> and <img src> references are bundled and rewritten to depth-aware relative paths of the form <prefix>assets/<subpath>, where prefix is './' for depth-0 pages and '../'.repeat(depth) for nested pages." },
{ "id": "detection_srcset", "weight": 5, "description": "Detection shape #2 (srcset multi-URL): each URL in the comma-separated list is rewritten independently to depth-aware relative form; descriptors (1x, 2x, 768w) preserved verbatim; whitespace between entries preserved." },
{ "id": "detection_inline_style", "weight": 3, "description": "Detection shape #3 (inline style=\"...url(...)...\"): background-image url() is rewritten to depth-aware relative form." },
{ "id": "detection_style_block_url", "weight": 4, "description": "Detection shape #4 (<style>-block url()): both @font-face src: url() and .grid background url() are rewritten to depth-aware relative form." },
{ "id": "depth_aware_paths", "weight": 6, "description": "Asset and internal-page references are emitted as depth-aware relative paths, NOT root-relative. From home (depth 0): './assets/foo'. From beers/index.html (depth 1): '../assets/foo' and '../about/index.html'. From docs/api/index.html (depth 2): '../../assets/foo'. Per migration-procedure.md § Reference shape." },
{ "id": "prefix_normalises_authoring", "weight": 4, "description": "Root-relative authoring ('/assets/generated/hero-1x.jpg') and bare-relative authoring ('assets/foo') are BOTH normalised to the depth-aware relative form on the emitted page. Authoring style is not preserved verbatim — the contract is 'one shape, works everywhere'." },
{ "id": "asset_subpath_preserved", "weight": 3, "description": "Source subdir structure preserved verbatim under stardust/migrated/assets/: generated/hero-1x.jpg, fonts/inter-var.woff2, photos/family portrait.jpg all land at the expected paths." },
{ "id": "percent_encoded_subpath", "weight": 3, "description": "The reference to '<prefix>assets/photos/family%20portrait.jpg' is preserved encoded in HTML but the file-system access decodes to 'photos/family portrait.jpg' and the file copies correctly." },
{ "id": "path_traversal_refused", "weight": 3, "description": "The '/assets/../etc/passwd' reference is refused: no copy, the HTML reference is left unchanged (so the issue surfaces visibly on deploy), and the page's _meta.json#migrationDecisions[] records kind: 'asset-path-traversal'." },
{ "id": "scheme_bearing_url_skipped", "weight": 2, "description": "The 'https://fonts.googleapis.com/...' stylesheet link is left external — not bundled, not rewritten, not flagged as missing. CDN URLs are by-design external." },
{ "id": "pagemap_constructed", "weight": 6, "description": "state.json.migrate.pageMap[] is built BEFORE per-page rendering and lists every in-scope source URL with its outputPath (URL-literal rule per migration-procedure.md § Output path mapping): /beers-bev/ -> beers/index.html, /about-us/history.html -> about-us/history.html (preserve .html leaf, no extra index.html). Each entry: { sourceUrl, outputPath, slug }." },
{ "id": "internal_links_via_pagemap", "weight": 5, "description": "Internal <a href> rewrites resolve via pageMap.outputPath lookup, NOT slug synthesis. Same-origin absolute hrefs (https://<origin>/path) are stripped to their path portion and looked up via pageMap, then emitted as depth-aware relative refs. A target URL with no pageMap entry is flagged data-broken-link='true' and logged under provenance.brokenInternalLinks[]." },
{ "id": "state_json_migrate_block", "weight": 6, "description": "state.json.migrate is written with: selfContained: true, outputDir, pageMap[] (built first), totalAssetsBundled, bundledAssets[] (unique subpath set), per-page assetsBundled counts, missingAssets[], cleanedAssets[]. Field order and shapes match migrate-output-format.md § State.json contract." },
{ "id": "self_contained_signal", "weight": 2, "description": "state.json.migrate.selfContained === true is the forward-compat signal — downstream consumers can test for it." },
{ "id": "cross_page_dedup", "weight": 3, "description": "When two pages reference the same asset, the bundler copies it exactly once. The global Set is seeded from state.json.migrate.bundledAssets[] so cross-run dedup works even when only some pages re-render. Each page emits the asset with its own depth-aware prefix; the underlying copy is shared." },
{ "id": "stale_cleanup_clean_flag", "weight": 3, "description": "Run 3 with --clean removes assets that were bundled in the prior run but are no longer referenced. --clean implies --force (every page re-renders so the bundledAssets set is the complete union; idempotent-skipped pages' references are not missed). state.json.migrate.cleanedAssets[] records what was removed. Without --clean, the run is additive and honors the idempotent skip." },
{ "id": "portability_audit_passes", "weight": 13, "description": "Phase 3 portability audit (per SKILL.md § Phase 3) passes: (a) no '../current/' escapes; (b) no absolute internal href/src ('grep -rE \"(href|src)=/[^/]\"' empty); (c) no absolute url() ('grep -rE \"url\\(\\s*[\\\"\\']?\\s*/[^/]\"' empty); (d) no directory-only hrefs ('grep -rE \"href=\\\"(\\.{0,2}/|[a-zA-Z0-9_-])[^:\\\"#?]*/\\\"\"' empty); (e) pagemap-audit.mjs exits 0; (f) file-protocol-audit.mjs exits 0. Any non-empty grep output or non-zero fixture exit fails the run." }
]
}