diff --git a/.changeset/calm-upgrades-plan.md b/.changeset/calm-upgrades-plan.md new file mode 100644 index 00000000..4d6212a3 --- /dev/null +++ b/.changeset/calm-upgrades-plan.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/nimbus-docs": minor +"@cloudflare/create-nimbus-docs": patch +--- + +Add a versioned breaking-change manifest, explicit reviewed upgrade baselines, agent-readable migration plans, shared `check` and `outdated` diagnostics, safe starter drift updates, starter agent upgrade guidance, and automatic migration guidance during Astro configuration. diff --git a/.changeset/clean-routes-report.md b/.changeset/clean-routes-report.md index 6877464f..b4256974 100644 --- a/.changeset/clean-routes-report.md +++ b/.changeset/clean-routes-report.md @@ -3,4 +3,4 @@ "@cloudflare/create-nimbus-docs": patch --- -Allow user-owned Astro pages and scaffolded Markdown and `llms.txt` endpoints to use native rendering semantics while retaining entrypoint-aware checks for active Nimbus contracts and composing with unrelated integration routes. These dynamic endpoints now resolve their payloads when rendered on request. Endpoint helpers now live at `@cloudflare/nimbus-docs/agent-endpoints`; the existing `@cloudflare/nimbus-docs/publication` entrypoint remains supported. +Allow user-owned Astro pages and scaffolded Markdown and `llms.txt` endpoints to use native rendering semantics while retaining entrypoint-aware checks for active Nimbus contracts and composing with unrelated integration routes. These dynamic endpoints now resolve their payloads when rendered on request. Endpoint helpers now live at `@cloudflare/nimbus-docs/agent-endpoints`; the existing `@cloudflare/nimbus-docs/publication` entrypoint remains supported. Sub-path sitemaps now list the deployment root once. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 04a101a5..ec927a29 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,6 +8,7 @@ - [ ] Correct tier (framework / starter / registry) per the boundary test - [ ] Edited `packages/nimbus-starter-source/`, not the `templates` branch - [ ] Changeset added (`create-nimbus-docs` changeset if the starter changed) +- [ ] Breaking changes are labeled `breaking-change` and add a linked upgrade manifest entry - [ ] `pnpm typecheck`, `pnpm -r test`, and `pnpm templates:check` all green
diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0faa4948 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + versioning-strategy: increase + schedule: + interval: weekly + day: tuesday + groups: + non-major: + update-types: + - minor + - patch + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691d5a17..35c4cfaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# Red/green signal on every PR: typecheck (whole workspace) + test + audit. +# Red/green signal on every PR: typecheck, test, and dependency regressions. on: pull_request: branches: [main] @@ -54,6 +54,8 @@ jobs: - run: pnpm --filter ./packages/nimbus-docs build + - run: pnpm test:upgrades + - run: pnpm -r test lint: @@ -106,31 +108,19 @@ jobs: exit 1 fi - audit: - name: Audit + dependency-review: + name: Dependency review runs-on: ubuntu-latest + if: github.event_name == 'pull_request' permissions: contents: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Reject vulnerable production dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: - node-version: 24 - cache: pnpm - - # `pnpm audit` needs the resolved tree to populate advisory paths; without - # an install it returns advisories with empty `paths` and the fail-closed - # shape guard rejects the run. - - run: pnpm install --frozen-lockfile - - # Fail closed: a non-JSON or unsupported audit response means this gate - # cannot classify published-package risk reliably. - - name: Audit published package prod deps - run: pnpm audit:published-prod - - - name: Report full workspace high+ audit - run: pnpm audit --audit-level high - continue-on-error: true + fail-on-severity: high + fail-on-scopes: runtime + license-check: false + show-patched-versions: true diff --git a/.github/workflows/freshness-guard.yml b/.github/workflows/freshness-guard.yml index 0f15dcdf..b4db7ce6 100644 --- a/.github/workflows/freshness-guard.yml +++ b/.github/workflows/freshness-guard.yml @@ -6,6 +6,7 @@ name: Freshness guard on: pull_request: branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] permissions: contents: read @@ -19,7 +20,19 @@ jobs: # Need the base branch present locally to diff against it. fetch-depth: 0 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Require a create-nimbus-docs changeset when templates change run: node scripts/freshness-guard.mjs env: BASE_REF: ${{ github.base_ref }} + - name: Validate upgrade declarations + run: pnpm upgrades:check + env: + BASE_REF: ${{ github.base_ref }} + BREAKING_CHANGE: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42169511..325fc132 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,8 @@ jobs: - run: pnpm install --frozen-lockfile + - run: pnpm upgrades:check + # Build only what gets published. The root `build` also builds the private # @nimbus/www site + starter source; a failure there must not block the npm # release. release.mjs re-verifies the templates against the packed bits. @@ -120,7 +122,7 @@ jobs: if: "!(github.event_name == 'workflow_dispatch' && inputs.publish_only)" uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0 with: - version: pnpm changeset version + version: pnpm changeset:version publish: node scripts/release.mjs publish commit: "chore: bump package versions" title: "chore: bump package versions" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ad796cf..e61499b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,7 @@ Before you open a PR: - Put the change in the right place: framework bugs and plumbing in `nimbus-docs`, styling and layout in the starter, optional extras in the registry. - Edit `packages/nimbus-starter-source/`, never the `templates` branch — that's generated, and direct edits get clobbered on the next release. - Add a changeset for anything user-facing. Starter edits need a `create-nimbus-docs` changeset, or the freshness guard fails the PR. +- For every intentional public API break, apply the `breaking-change` PR label and add a linked entry to the comprehensive upgrade manifest. Every entry carries manual guidance; add a migration ID, detector, transform, bounded task, and focused fixtures only when maintainers deliberately classify the change as common, mechanical, and canonically detectable. Run `pnpm upgrades:check`. CI verifies the declaration, pending changeset, and manifest continuity. - Check that `pnpm typecheck`, `pnpm -r test`, and `pnpm templates:check` pass. ### Local development diff --git a/apps/www/nimbus.json b/apps/www/nimbus.json new file mode 100644 index 00000000..230eb550 --- /dev/null +++ b/apps/www/nimbus.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://nimbus-docs.com/schema/nimbus.json", + "lastReviewedNimbusVersion": "0.13.1" +} diff --git a/apps/www/public/schema/nimbus.json b/apps/www/public/schema/nimbus.json new file mode 100644 index 00000000..df84a803 --- /dev/null +++ b/apps/www/public/schema/nimbus.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nimbus-docs.com/schema/nimbus.json", + "title": "Nimbus project record", + "type": "object", + "properties": { + "$schema": { "type": "string" }, + "version": { "type": ["string", "null"] }, + "lastReviewedNimbusVersion": { "type": ["string", "null"] }, + "templatesTag": { "type": ["string", "null"] }, + "variant": { "type": ["string", "null"] }, + "registry": { "type": "string" }, + "reconstructed": { "type": "boolean" }, + "preview": { "type": "object" }, + "serverOutput": { + "type": "object", + "properties": { + "adapter": { "type": "string" } + }, + "required": ["adapter"], + "additionalProperties": true + }, + "install": { + "type": "object", + "properties": { + "root": { "type": "string" }, + "aliases": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "type": { "type": "string" }, + "version": { "type": ["string", "null"] }, + "source": { "type": ["string", "null"] }, + "hash": { "type": ["string", "null"] }, + "files": { "type": "array", "items": { "type": "string" } }, + "modified": { "type": "boolean" }, + "handAuthored": { "type": "boolean" } + }, + "required": ["slug", "type", "source", "hash", "files"], + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/apps/www/src/content/docs/cli.mdx b/apps/www/src/content/docs/cli.mdx index eeca80d6..c39479d0 100644 --- a/apps/www/src/content/docs/cli.mdx +++ b/apps/www/src/content/docs/cli.mdx @@ -1,6 +1,6 @@ --- title: CLI -description: The nimbus-docs CLI — list what's available, add it to your project, lint your content. +description: Use the nimbus-docs CLI to install registry features, migrate package APIs, review copied code, and check content. sidebar: order: 2 --- @@ -47,7 +47,7 @@ If a component is already installed, `add` keeps your copy — it never clobbers -`--yes` assents to prompts (e.g. dependency installs) without touching existing files — so it's safe in CI. Use `--overwrite` when you actually mean "replace my files." +For `add`, `--yes` assents to prompts such as dependency installs but still keeps existing files. Use `--overwrite` when you actually mean "replace my files." Once installed, the component lives in your repo. Edit freely — there's no upstream API to break. Each `add` also appends an entry to your [`nimbus.json`](/project-structure) — slug, source registry, the registry release it came from, and a content hash — so later upgrades can track what you own. @@ -107,23 +107,56 @@ It scans your installed components, matches each against the registry, and write - **modified** — you've edited it; the record keeps the source identity so upgrades can still compare. - **hand-authored** — yours, from no registry. -The starter version and `templates-v*` tag can't be recovered from the repo alone, so they're left blank (and flagged `reconstructed`) for you to fill in if you know them. +The starter version, `templates-v*` tag, and previously reviewed Nimbus version can't be recovered from the repo alone, so they're left blank (and flagged `reconstructed`). Use `migrate --from ` to establish the upgrade range. ## Keeping up to date -You own your files, so upgrades are opt-in — nothing changes under you. Two commands, one for each tier: +Package managers update Nimbus itself; Nimbus updates known API usage and reviews copied code: + +```sh +pnpm up @cloudflare/nimbus-docs --latest +pnpm exec nimbus-docs migrate +pnpm exec nimbus-docs outdated +``` + +### `nimbus-docs migrate` + +Composes every declared breaking change between the project's `lastReviewedNimbusVersion` and the installed Nimbus version. It shows complete diffs for statically proven edits and bounded review tasks for everything else. Customized or ambiguous code is never forced. + +Existing projects without a reviewed baseline must provide the exact Nimbus version whose migrations they last completed: + +```sh +pnpm exec nimbus-docs migrate --from PREVIOUS_VERSION +``` + +Use `--dry-run` or `--diff` for a read-only plan, `--yes --json` for an agent-safe apply loop, and `--print` for a self-contained Markdown handoff. A computed Astro `srcDir` can be supplied explicitly with `--src-dir `. + +Nimbus applies an edit only when it recognizes the source and can prove the change is safe. Customized or ambiguous code remains unchanged and is returned as a review task. `migrate --print` emits that task for any agent or workflow; Nimbus does not launch one itself. + +Migration output includes the selected version range, required reviews, planned diffs, blockers, and errors. Files outside the reported scan boundary are not claimed as checked. + +After completing all reported work, explicitly confirm it and advance the committed reviewed baseline: + +```sh +pnpm exec nimbus-docs migrate --from PREVIOUS_VERSION --yes +``` + +Repeat `--from` only when the project has no recorded baseline. A clean interactive rerun asks before recording the reviewed version; agents provide that consent with `--yes`. Nimbus never records completion while a detectable migration remains. Then run `outdated` to review user-owned starter and registry code, followed by `nimbus-docs check`, `astro check`, and the production build. The `migrate` output is the version-selected upgrade guide. ### `nimbus-docs outdated` -The read-only "am I behind?" check, across both tiers: +The read-only "am I behind?" overview across package APIs, starter files, and registry components: -- **Registry components** — compares each recorded content hash against the current registry; run `add --overwrite` to update. -- **Starter files** — compares your scaffolded files against the upstream `templates-v*` tag. Because those files came from a tag that was never in your git history, plain `git diff` can't show this. Content files are hidden by default (`--all` to include them). +- **Package APIs** — points pending source migrations and version-selected reviews to `migrate`. +- **Registry components** — compares each recorded content hash against the current registry and classifies the recorded local footprint. Registry updates remain review-only because overwrite can also affect dependencies. +- **Starter files** — compares your scaffolded files against the upstream `templates-v*` tag, including additions and removals. Because those files came from a tag that was never in your git history, plain `git diff` can't show this. Content files are hidden by default (`--all` to include them). + +Pass `--json` for deterministic agent-readable findings. Projects without complete `nimbus.json` provenance still receive Package API results and an explicit partial-coverage result. ### `nimbus-docs diff [file]` @@ -132,11 +165,11 @@ Read-only detail for starter files — what you changed, and what changed upstre -Each file is one of: **clean to pull** (upstream changed, you didn't), **hand-merge** (you both changed it), or **your changes** (you edited it, upstream didn't). For a clean file you can let the CLI write the upstream version: +Each file is one of: **clean to pull** (upstream changed, you didn't), **added/removed upstream**, **hand-merge** (you both changed it), or **your changes** (you edited it, upstream didn't). For clean updates, additions, and removals, you can let the CLI apply one reviewed change: -`--apply` is explicit and per-file, and refuses anything you've edited — it only pulls clean upstream changes, never merges. Review with `git diff` afterward. Pass `--to ` to target a specific tag, or `--template-dir ` to compare offline against a local checkout. +`--apply` is explicit and per-file, rejects symlink/path escapes, and rechecks the clean preimage or absence before writing — it never merges. Review with `git diff` afterward. Pass `--to ` to target a specific tag, or `--template-dir ` to compare offline against a local checkout. ## `nimbus-docs lint` diff --git a/package.json b/package.json index 45e11c6b..af46b8fc 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,20 @@ "typecheck": "pnpm --filter ./packages/nimbus-docs build && pnpm -r typecheck", "lint": "eslint", "lint:fix": "eslint --fix", - "audit:published-prod": "node scripts/audit-published-prod.mjs", "dev": "pnpm --filter nimbus-starter-source dev", "build:templates": "node packages/create-nimbus-docs/scripts/copy-template.mjs", "api-reference:check": "node scripts/api-reference-check.mjs", "workers-feasibility:check": "node scripts/workers-feasibility-check.mjs", "templates:check": "node scripts/templates-check.mjs", "templates:sync": "node scripts/sync-templates-repo.mjs", + "upgrades:check": "node scripts/upgrade-manifest.mjs", + "test:upgrades": "node --test scripts/upgrade-manifest.test.mjs scripts/sync-reviewed-baselines.test.mjs", "local": "node scripts/local.mjs", "local:auto": "node scripts/local.mjs --auto", "local:reset": "node scripts/local.mjs --auto --reset", "local:add": "node scripts/local-add.mjs", "changeset": "changeset", - "changeset:version": "changeset version", + "changeset:version": "changeset version && node scripts/sync-reviewed-baselines.mjs", "changeset:publish": "changeset publish" }, "engines": { diff --git a/packages/create-nimbus-docs/scripts/copy-template.mjs b/packages/create-nimbus-docs/scripts/copy-template.mjs index bd24e7b3..d6f9db5a 100644 --- a/packages/create-nimbus-docs/scripts/copy-template.mjs +++ b/packages/create-nimbus-docs/scripts/copy-template.mjs @@ -53,6 +53,7 @@ const EXCLUDED_DIRS = new Set([ "dist", "pnpm-lock.yaml", ".nimbus", + "nimbus.json", // Templates dir holds per-variant content overrides for the generator. // It is internal to the source tree and never ships in a template. "templates", diff --git a/packages/create-nimbus-docs/src/scaffold.ts b/packages/create-nimbus-docs/src/scaffold.ts index 736dcb8e..7f8ad364 100644 --- a/packages/create-nimbus-docs/src/scaffold.ts +++ b/packages/create-nimbus-docs/src/scaffold.ts @@ -4,6 +4,7 @@ import { cpSync, existsSync, lstatSync, + readFileSync, realpathSync, readdirSync, renameSync, @@ -68,6 +69,7 @@ function writeNimbusJson( const record = { $schema: "https://nimbus-docs.com/schema/nimbus.json", version, + lastReviewedNimbusVersion: preview ? null : frameworkVersion(target), templatesTag: preview ? null : `templates-v${version}`, variant: options.content, registry: DEFAULT_REGISTRY_URL, @@ -89,6 +91,23 @@ function writeNimbusJson( ); } +function frameworkVersion(target: string): string | null { + try { + const pkg = JSON.parse( + readFileSync(join(target, "package.json"), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const spec = pkg.dependencies?.["@cloudflare/nimbus-docs"] ?? + pkg.devDependencies?.["@cloudflare/nimbus-docs"]; + if (typeof spec !== "string") return null; + return /^[~^]?([0-9]+\.[0-9]+\.[0-9]+)(?:$|[-+\s])/.exec(spec.trim())?.[1] ?? null; + } catch { + return null; + } +} + // Entries that must never survive into a scaffolded project, whether the // source was a giget download or a local `--template-dir`. `.nimbus` is // gitignored build output (lint.json / routes.json) — defense-in-depth for any diff --git a/packages/create-nimbus-docs/test/scaffold.test.ts b/packages/create-nimbus-docs/test/scaffold.test.ts index af440a02..858aee52 100644 --- a/packages/create-nimbus-docs/test/scaffold.test.ts +++ b/packages/create-nimbus-docs/test/scaffold.test.ts @@ -130,6 +130,7 @@ test("happy path writes and transforms the project", async () => { fs.readFileSync(path.join(target, "nimbus.json"), "utf8"), ); assert.equal(typeof nimbus.version, "string"); + assert.equal(nimbus.lastReviewedNimbusVersion, null); assert.equal(nimbus.templatesTag, `templates-v${nimbus.version}`); assert.equal(nimbus.install.root, "src"); assert.deepEqual(nimbus.install.aliases, { "@/*": "src/*" }); @@ -139,6 +140,20 @@ test("happy path writes and transforms the project", async () => { } }); +test("records the installed Nimbus version as the fresh upgrade baseline", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(`{ "name": "template", "version": "0.0.0", "dependencies": { "@cloudflare/nimbus-docs": "^0.13.1" } }`); + try { + await scaffold({ ...BASE_OPTIONS, dir: "my-docs" }, internals(cwd, tmpl)); + const nimbus = JSON.parse( + fs.readFileSync(path.join(cwd, "my-docs", "nimbus.json"), "utf8"), + ); + assert.equal(nimbus.lastReviewedNimbusVersion, "0.13.1"); + } finally { + cleanup(cwd, tmpl); + } +}); + test("non-TTY scaffolds report each completed step without spinner frames", async () => { const cwd = makeCwd(); const tmpl = makeTemplate(); @@ -217,6 +232,7 @@ test("preview mode scaffolds bundled templates and records preview provenance", fs.readFileSync(path.join(cwd, "my-docs", "nimbus.json"), "utf8"), ); assert.equal(nimbus.templatesTag, null); + assert.equal(nimbus.lastReviewedNimbusVersion, null); assert.deepEqual(nimbus.preview, { pr: "42", templates: "bundled" }); } finally { cleanup(cwd, templates); diff --git a/packages/nimbus-docs/src/_internal/authored-link-normalizer.ts b/packages/nimbus-docs/src/_internal/authored-link-normalizer.ts index 082a4c2a..0a00ab77 100644 --- a/packages/nimbus-docs/src/_internal/authored-link-normalizer.ts +++ b/packages/nimbus-docs/src/_internal/authored-link-normalizer.ts @@ -1,6 +1,10 @@ export type AuthoredLinkNormalizer = ( source: string, - options: { base: string; sourceId?: string }, + options: { + base: string; + sourceId?: string; + format?: "markdown" | "mdx"; + }, ) => string; const NORMALIZER_KEY = Symbol.for( diff --git a/packages/nimbus-docs/src/_internal/authored-links.ts b/packages/nimbus-docs/src/_internal/authored-links.ts index 8f134c87..66a98302 100644 --- a/packages/nimbus-docs/src/_internal/authored-links.ts +++ b/packages/nimbus-docs/src/_internal/authored-links.ts @@ -1,4 +1,5 @@ -import { mdxToMdast } from "satteri"; +import { fromHtml } from "hast-util-from-html"; +import { markdownToMdast, mdxToMdast } from "satteri"; import ts from "typescript"; interface MdNode { @@ -13,6 +14,17 @@ interface MdNode { }; } +interface HtmlNode { + type?: string; + tagName?: unknown; + properties?: unknown; + children?: unknown; + content?: unknown; + position?: { + start?: { offset?: number }; + }; +} + function hasCanonicalSegments(pathname: string): boolean { for (const rawSegment of pathname.split("/")) { let segment = rawSegment; @@ -36,6 +48,7 @@ function hasCanonicalSegments(pathname: string): boolean { export interface NormalizeAuthoredLinksOptions { base: string; sourceId?: string; + format?: "markdown" | "mdx"; } function fail( @@ -81,6 +94,62 @@ function assertCanonicalDestination( } } +function browserNormalizedDestination(destination: string): string { + const normalized = destination.replace(/[\t\n\r]/gu, ""); + let start = 0; + let end = normalized.length; + while (start < end && normalized.charCodeAt(start) <= 0x20) start += 1; + while (end > start && normalized.charCodeAt(end - 1) <= 0x20) end -= 1; + const trimmed = normalized.slice(start, end); + const suffixStart = trimmed.search(/[?#]/u); + if (suffixStart === -1) return trimmed.replaceAll("\\", "/"); + return `${trimmed.slice(0, suffixStart).replaceAll("\\", "/")}${trimmed.slice(suffixStart)}`; +} + +function couldBeRootRelativeDestination(destination: string): boolean { + const normalized = browserNormalizedDestination(destination); + return ( + (destination.startsWith("/") && !destination.startsWith("//")) || + (normalized.startsWith("/") && !normalized.startsWith("//")) + ); +} + +function rootRelativeInsertionOffset( + destination: string, + source: string, + sourceId: string | undefined, + offset: number, +): number | null { + const normalized = browserNormalizedDestination(destination); + const authoredRoot = destination.startsWith("/") && !destination.startsWith("//"); + const normalizedRoot = normalized.startsWith("/") && !normalized.startsWith("//"); + let insertionOffset = offset; + if (normalized !== destination && (authoredRoot || normalizedRoot)) { + let leadingSpaces = 0; + let trailingSpaces = 0; + while (destination[leadingSpaces] === " ") leadingSpaces += 1; + while (destination[destination.length - trailingSpaces - 1] === " ") { + trailingSpaces += 1; + } + const literalSpacesOnly = + destination.slice(leadingSpaces, destination.length - trailingSpaces) === + normalized && + source.slice(offset, offset + destination.length) === destination; + if (!literalSpacesOnly) { + fail( + "destination escapes its canonical path through URL control normalization", + source, + sourceId, + offset, + ); + } + insertionOffset += leadingSpaces; + } + if (!normalizedRoot) return null; + assertCanonicalDestination(normalized, source, sourceId, offset); + return insertionOffset; +} + function buildOffsetMap(source: string): number[] { const offsets = [0]; let index = 0; @@ -179,6 +248,117 @@ function visit(node: MdNode, callback: (node: MdNode) => void): void { } } +function visitHtml(node: HtmlNode, callback: (node: HtmlNode) => void): void { + callback(node); + for (const descendants of [node.children, (node.content as HtmlNode | undefined)?.children]) { + if (!Array.isArray(descendants)) continue; + for (const child of descendants) { + if (child && typeof child === "object") visitHtml(child as HtmlNode, callback); + } + } +} + +function htmlAttributeValueOffset( + raw: string, + tagStart: number, + attributeName: string, +): number | null { + const isWhitespace = (value: string | undefined) => + value !== undefined && /[\t\n\f\r ]/u.test(value); + let index = tagStart; + if (raw[index] !== "<") return null; + index += 1; + while (index < raw.length && !isWhitespace(raw[index]) && !/[/>]/u.test(raw[index]!)) { + index += 1; + } + + while (index < raw.length) { + while (isWhitespace(raw[index])) index += 1; + if (raw[index] === ">" || (raw[index] === "/" && raw[index + 1] === ">")) { + return null; + } + + const nameStart = index; + while ( + index < raw.length && + !isWhitespace(raw[index]) && + !/[=/>]/u.test(raw[index]!) + ) { + index += 1; + } + if (index === nameStart) return null; + const name = raw.slice(nameStart, index).toLowerCase(); + while (isWhitespace(raw[index])) index += 1; + if (raw[index] !== "=") continue; + index += 1; + while (isWhitespace(raw[index])) index += 1; + + const quote = raw[index] === '"' || raw[index] === "'" ? raw[index] : null; + if (quote) index += 1; + const valueStart = index; + if (quote) { + while (index < raw.length && raw[index] !== quote) index += 1; + if (index >= raw.length) return null; + index += 1; + } else { + while ( + index < raw.length && + !isWhitespace(raw[index]) && + raw[index] !== ">" + ) { + index += 1; + } + } + if (name === attributeName) return valueStart; + } + return null; +} + +function staticHtmlHrefOffsets( + raw: string, + source: string, + sourceId: string | undefined, + sourceStart: number, +): number[] { + let tree: HtmlNode; + try { + tree = fromHtml(raw, { fragment: true }) as HtmlNode; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`could not parse HTML: ${detail}`, source, sourceId, sourceStart); + } + const offsets: number[] = []; + visitHtml(tree, (node) => { + if ( + node.type !== "element" || + (node.tagName !== "a" && node.tagName !== "area") + ) { + return; + } + const properties = node.properties; + if (!properties || typeof properties !== "object") return; + const href = (properties as Record).href; + if (typeof href !== "string") return; + const tagStart = node.position?.start?.offset; + if (typeof tagStart !== "number") { + fail("missing HTML anchor source position", source, sourceId, sourceStart); + } + const localOffset = htmlAttributeValueOffset(raw, tagStart, "href"); + if (localOffset === null) { + fail("could not locate HTML href", source, sourceId, sourceStart + tagStart); + } + const offset = sourceStart + localOffset; + const insertionOffset = rootRelativeInsertionOffset( + href, + source, + sourceId, + offset, + ); + if (insertionOffset !== null) offsets.push(insertionOffset); + }); + return offsets; +} + function isHref(node: MdNode, name: string): boolean { return ( name === "href" || (node.name === "a" && name.toLowerCase() === "href") @@ -197,9 +377,7 @@ function expressionLiteral( if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { return { value: node.text, - slashOffset: node.text.startsWith("/") - ? node.getStart(sourceFile) + 1 + sourceBase - : -1, + slashOffset: node.getStart(sourceFile) + 1 + sourceBase, }; } if (ts.isConditionalExpression(node)) { @@ -256,6 +434,11 @@ function staticHrefOffsets( if (!Array.isArray(node.attributes)) { fail("missing JSX attributes", source, sourceId, sourceStart); } + if (!node.attributes.some((attribute) => + attribute?.type === "mdxJsxAttribute" && + typeof attribute.name === "string" && + isHref(node, attribute.name) + )) return []; const key = jsxRangeKey(sourceStart, sourceStart + raw.length); if (!parsedRanges.has(key)) { const prefix = "const element = ("; @@ -267,23 +450,24 @@ function staticHrefOffsets( ts.ScriptKind.TSX, ); const sourceBase = sourceStart - prefix.length; + const matchingStart: ParsedJsxRange[] = []; const collect = (candidate: ts.Node) => { if ( ts.isJsxElement(candidate) || ts.isJsxSelfClosingElement(candidate) || ts.isJsxFragment(candidate) ) { - parsedRanges.set( - jsxRangeKey( - candidate.getStart(parsed) + sourceBase, - candidate.getEnd() + sourceBase, - ), - { node: candidate, sourceFile: parsed, sourceBase }, - ); + const range = { node: candidate, sourceFile: parsed, sourceBase }; + const start = candidate.getStart(parsed) + sourceBase; + parsedRanges.set(jsxRangeKey(start, candidate.getEnd() + sourceBase), range); + if (start === sourceStart) matchingStart.push(range); } ts.forEachChild(candidate, collect); }; collect(parsed); + if (!parsedRanges.has(key) && matchingStart.length === 1) { + parsedRanges.set(key, matchingStart[0]!); + } } const parsedRange = parsedRanges.get(key); if (!parsedRange) { @@ -365,19 +549,16 @@ function staticHrefOffsets( parsed, sourceBase, ); - if ( - isHref(node, attribute.name) && - literal?.value.startsWith("/") && - !literal.value.startsWith("//") - ) { - assertCanonicalDestination( + const insertionOffset = + isHref(node, attribute.name) && literal + ? rootRelativeInsertionOffset( literal.value, source, sourceId, - literal.slashOffset, - ); - offsets.push(literal.slashOffset); - } + literal.slashOffset, + ) + : null; + if (insertionOffset !== null) offsets.push(insertionOffset); continue; } @@ -394,14 +575,15 @@ function staticHrefOffsets( ); } const valueStart = property.initializer.getStart(parsed) + 1 + sourceBase; - if ( - isHref(node, attribute.name) && - attribute.value.startsWith("/") && - !attribute.value.startsWith("//") - ) { - assertCanonicalDestination(attribute.value, source, sourceId, valueStart); - offsets.push(valueStart); - } + const insertionOffset = isHref(node, attribute.name) + ? rootRelativeInsertionOffset( + attribute.value, + source, + sourceId, + valueStart, + ) + : null; + if (insertionOffset !== null) offsets.push(insertionOffset); } return offsets; } @@ -414,7 +596,11 @@ export function normalizeAuthoredLinks( let tree: MdNode; try { - tree = mdxToMdast(source) as MdNode; + const parse = options.format === "markdown" || + (options.format === undefined && options.sourceId?.endsWith(".md")) + ? markdownToMdast + : mdxToMdast; + tree = parse(source) as MdNode; } catch (error) { const detail = error instanceof Error ? error.message : String(error); const location = detail.match(/^(\d+):(\d+):\s*/); @@ -428,12 +614,12 @@ export function normalizeAuthoredLinks( const offsetMap = buildOffsetMap(source); const insertions = new Set(); const parsedJsxRanges = new Map(); + let rawTextElement: string | null = null; visit(tree, (node) => { if ( (node.type === "link" || node.type === "definition") && typeof node.url === "string" && - node.url.startsWith("/") && - !node.url.startsWith("//") + couldBeRootRelativeDestination(node.url) ) { const offset = destinationOffset( source, @@ -441,8 +627,45 @@ export function normalizeAuthoredLinks( offsetMap, options.sourceId, ); - assertCanonicalDestination(node.url, source, options.sourceId, offset); - insertions.add(offset); + const insertionOffset = rootRelativeInsertionOffset( + node.url, + source, + options.sourceId, + offset, + ); + if (insertionOffset !== null) { + insertions.add(insertionOffset); + return; + } + } + + if (node.type === "html") { + const [start, end] = nodeRange(node, offsetMap, source, options.sourceId); + const raw = source.slice(start, end); + if (rawTextElement) { + if (raw.toLowerCase().includes(`])/iu.exec( + raw, + ); + if ( + rawTextStart && + !raw.toLowerCase().includes(` path.join(root, name)).filter((file) => + fs.existsSync(file), + ); + if (configPaths.length === 0) return { srcDir: path.join(root, "src") }; + if (configPaths.length !== 1) { + return { srcDir: null, error: "Multiple Astro config candidates exist. Re-run with --src-dir ." }; + } + + const configPath = configPaths[0]!; + let source: string; + try { + source = fs.readFileSync(configPath, "utf8"); + } catch (error) { + return { srcDir: null, error: `Could not read ${path.basename(configPath)}: ${errorMessage(error)}` }; + } + const parsed = parseSource(configPath, source); + if (parsed.error) return { srcDir: null, error: parsed.error }; + + const config = astroConfigObject(parsed.file); + if (!config || hasDynamicOrDuplicateProperties(config)) { + return { srcDir: null, error: "Astro config is computed or spread. Re-run with --src-dir ." }; + } + const srcDirProperty = config.properties.find((item) => propertyName(item.name) === "srcDir") ?? null; + if (!srcDirProperty) return { srcDir: path.join(root, "src") }; + if (!ts.isPropertyAssignment(srcDirProperty)) { + return { srcDir: null, error: "Astro srcDir is computed or imported. Re-run with --src-dir ." }; + } + const value = unwrapParentheses(srcDirProperty.initializer); + if (!ts.isStringLiteralLike(value)) { + return { srcDir: null, error: "Astro srcDir is computed or imported. Re-run with --src-dir ." }; + } + const resolved = path.resolve(root, value.text); + if (!isInside(root, resolved)) { + return { srcDir: null, error: `Astro srcDir resolves outside the selected project: ${value.text}.` }; + } + const containment = validateExistingPath(root, resolved); + return containment ? { srcDir: null, error: containment } : { srcDir: resolved }; +} + +export function discoverMigrations(options: { + projectRoot: string; + srcDir?: string; + srcDirOverride?: string; + allowUnresolvedLayout?: boolean; +}): MigrationDiscovery { + const projectRoot = path.resolve(options.projectRoot); + try { + const layout = options.srcDir + ? { srcDir: path.resolve(options.srcDir) as string | null, error: undefined as string | undefined } + : resolveMigrationSrcDir(projectRoot, options.srcDirOverride); + if (!layout.srcDir || layout.error || !isInside(projectRoot, layout.srcDir)) { + if ( + options.allowUnresolvedLayout && + options.srcDirOverride === undefined && + layout.error && + /^(?:Astro config is computed or spread|Astro srcDir is computed or imported)\./.test(layout.error) + ) { + return { projectRoot, srcDir: null, plans: [] }; + } + return unresolvedDiscovery( + projectRoot, + layout.error ?? "The resolved Astro srcDir is outside the selected project.", + ); + } + const containment = validateExistingPath(projectRoot, layout.srcDir); + if (containment) { + return unresolvedDiscovery(projectRoot, containment); + } + + const plans = MIGRATION_CATALOG + .map((entry) => entry.discover({ projectRoot, srcDir: layout.srcDir! })) + .filter((plan): plan is MigrationPlan => plan !== null) + .sort((a, b) => a.id.localeCompare(b.id)); + return { projectRoot, srcDir: layout.srcDir, plans }; + } catch (error) { + return unresolvedDiscovery(projectRoot, `Migration discovery failed: ${errorMessage(error)}`); + } +} + +function unresolvedDiscovery(projectRoot: string, message: string): MigrationDiscovery { + return { + projectRoot, + srcDir: null, + plans: [ + { + id: PARTIAL_RESOLVER_MIGRATION_ID, + introducedIn: PARTIAL_RESOLVER_INTRODUCED_IN, + summary: "Nimbus could not establish the partial-resolver migration scan boundary.", + locations: [], + changes: [], + blockers: [{ code: "project-layout-unresolved", message }], + instructions: [ + "Resolve Astro srcDir statically or rerun nimbus-docs migrate with a project-contained --src-dir path.", + "Rerun nimbus-docs migrate, nimbus-docs check, astro check, and the project build.", + ], + }, + ], + coverage: { code: "project-layout-unresolved", message }, + }; +} + +function discoverPartialResolverMigration(context: { + projectRoot: string; + srcDir: string; +}): MigrationPlan | null { + const route = discoverRouteCandidates(context.projectRoot, path.join(context.srcDir, "pages")); + scanRemainingPartialHeadings(context.projectRoot, context.srcDir, route); + if (route.locations.length === 0 && route.blockers.length === 0) return null; + + const blockers = [...route.blockers]; + if (route.callsiteCount !== 1) { + blockers.push({ + code: "multiple-callsites", + message: `Expected one route call across the project, found ${route.callsiteCount}.`, + }); + } + + const config = planConfigEdit(context.projectRoot); + if (config.locations) route.locations.push(...config.locations); + if (config.blocker) blockers.push(config.blocker); + + let changes: MigrationChange[] = []; + if (blockers.length === 0 && route.calls[0] && config.change) { + changes = [ + config.change, + { + file: route.calls[0].file, + absoluteFile: route.calls[0].absoluteFile, + before: route.calls[0].source, + after: removeLegacyArgument(route.calls[0]), + operation: "update" as const, + }, + ].sort((a, b) => a.file.localeCompare(b.file)); + for (const change of changes) { + const error = validatePostimage(change); + if (error) blockers.push({ code: "parse-error", file: change.file, message: error }); + } + if (blockers.length > 0) changes = []; + } + + return { + id: PARTIAL_RESOLVER_MIGRATION_ID, + introducedIn: PARTIAL_RESOLVER_INTRODUCED_IN, + summary: "Move route-level partial resolution to the Nimbus integration.", + locations: dedupeLocations(route.locations).sort(locationOrder), + changes, + blockers: dedupeBlockers(blockers), + instructions: [ + 'Configure markdown.partialResolver as { revision: "partial-resolver-v1", resolve: ({ file, product }) => product ? `${product}/${file}` : file } in astro.config.', + "Preserve the exact product-prefixed file ID behavior shown by that resolve callback.", + "Call getDocsPageProps with only Astro.", + "Rerun nimbus-docs migrate, nimbus-docs check, astro check, and the project build.", + ], + }; +} + +function discoverRouteCandidates(projectRoot: string, pagesRoot: string): RouteAnalysis { + const result: RouteAnalysis = { calls: [], callsiteCount: 0, locations: [], blockers: [] }; + const symlinks = routeSymlinks(pagesRoot); + for (const symlink of symlinks) { + const file = relativeFile(projectRoot, symlink); + result.locations.push({ file, line: 1, column: 1 }); + result.blockers.push({ code: "symlink-escape", file, message: "Symlinked route coverage requires manual review." }); + } + if (symlinks.includes(pagesRoot)) return result; + for (const { abs } of walkFilesSync(pagesRoot, { extensions: [".astro"], skipDotDirs: false })) { + const file = relativeFile(projectRoot, abs); + let source: string; + try { + source = fs.readFileSync(abs, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + const extracted = extractAstroFrontmatter(source); + if (!extracted) { + const opening = /^\uFEFF?[ \t]*(?:\r?\n[ \t]*)*---[ \t]*\r?\n/.exec(source); + if (!opening) continue; + const recovered = parseSource(abs, source.slice(opening[0].length)); + const directCalls = findDirectProseCalls(recovered.file); + result.callsiteCount += directCalls.length; + if ( + findPartialHeadingsProperties(recovered.file).length > 0 || + directCalls.some(isPotentiallyLegacyCall) + ) { + result.locations.push({ file, line: 1, column: 1 }); + result.blockers.push({ code: "parse-error", file, message: "Astro frontmatter is not a complete delimited block." }); + } + continue; + } + const parsed = parseSource(abs, extracted.script); + const directCalls = findDirectProseCalls(parsed.file); + result.callsiteCount += directCalls.length; + if (parsed.error) { + if ( + findPartialHeadingsProperties(parsed.file).length === 0 && + !directCalls.some(isPotentiallyLegacyCall) + ) continue; + result.locations.push({ file, ...diagnosticLocation(extracted.script, parsed.diagnosticStart, extracted.offset) }); + result.blockers.push({ code: "parse-error", file, message: parsed.error }); + continue; + } + + const imports = ROUTE_ENTRYPOINTS.flatMap((moduleName) => + getNamedImports(parsed.file, moduleName, "getDocsPageProps") + ); + if (imports.length === 0) continue; + const calls = imports.flatMap((item) => findIdentifierCalls(parsed.file, item.name.text)); + const legacyCalls = calls.filter(isPotentiallyLegacyCall); + if (legacyCalls.length === 0) continue; + const importLocation = imports[0] + ? locationForNode(file, source, imports[0], parsed.file, extracted.offset) + : { file, line: 1, column: 1 }; + result.locations.push(importLocation); + if (imports.length !== 1) { + result.blockers.push({ code: "unsupported-source", file, message: "Expected one getDocsPageProps import from a supported Nimbus entrypoint." }); + continue; + } + const imported = imports[0]!; + const localName = imported.name.text; + const typeOnly = imported.isTypeOnly || imported.parent.parent.isTypeOnly; + if (typeOnly || imported.propertyName || localName !== "getDocsPageProps") { + result.blockers.push({ code: "unsupported-source", file, message: "Aliased getDocsPageProps imports require manual migration." }); + } + if (hasOtherDeclaration(parsed.file, localName, imported)) { + result.blockers.push({ code: "unsupported-source", file, message: "Another declaration uses the getDocsPageProps binding name." }); + } + + if (hasIndirectReference(parsed.file, localName, imported)) { + result.blockers.push({ code: "captured-binding", file, message: "Indirect use of the imported getDocsPageProps binding requires manual migration." }); + } + for (const call of legacyCalls) { + const location = locationForNode(file, source, call, parsed.file, extracted.offset); + result.locations.push(location); + const blocker = canonicalRouteBlocker(call, parsed.file, file); + if (blocker || typeOnly || imported.propertyName || localName !== "getDocsPageProps") { + if (blocker) result.blockers.push(blocker); + continue; + } + result.calls.push({ + absoluteFile: abs, + file, + source, + sourceFile: parsed.file, + scriptOffset: extracted.offset, + call, + location, + }); + } + } + return result; +} + +function scanRemainingPartialHeadings(projectRoot: string, srcDir: string, route: RouteAnalysis): void { + const ignored = new Set(); + for (const legacy of route.calls) { + const options = unwrapParentheses(legacy.call.arguments[1]!); + if (!ts.isObjectLiteralExpression(options)) continue; + const partial = property(options, "partialHeadings"); + if (partial) { + ignored.add(`${legacy.absoluteFile}\0${partial.getStart(legacy.sourceFile) + legacy.scriptOffset}\0${partial.getEnd() + legacy.scriptOffset}`); + } + } + + for (const { abs } of walkFilesSync(srcDir, { + extensions: [".astro", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], + skipDotDirs: false, + })) { + const file = relativeFile(projectRoot, abs); + let source: string; + try { + source = fs.readFileSync(abs, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + + let script = source; + let offset = 0; + if (abs.endsWith(".astro")) { + const extracted = extractAstroFrontmatter(source); + if (!extracted) { + const opening = /^\uFEFF?[ \t]*(?:\r?\n[ \t]*)*---[ \t]*\r?\n/.exec(source); + if (!opening || !source.includes("partialHeadings")) continue; + const recovered = parseSource(abs, source.slice(opening[0].length)); + if (findPartialHeadingsProperties(recovered.file).length === 0) continue; + route.locations.push({ file, line: 1, column: 1 }); + route.blockers.push({ code: "parse-error", file, message: "Astro frontmatter is not a complete delimited block." }); + continue; + } + script = extracted.script; + offset = extracted.offset; + } + if (!script.includes("partialHeadings")) continue; + + const parsed = parseSource(abs, script); + const properties = findPartialHeadingsProperties(parsed.file); + if (parsed.error) { + if (properties.length === 0) continue; + route.locations.push({ file, ...diagnosticLocation(source, parsed.diagnosticStart, offset) }); + route.blockers.push({ code: "parse-error", file, message: parsed.error }); + continue; + } + + for (const node of properties) { + const start = node.getStart(parsed.file) + offset; + const end = node.getEnd() + offset; + if (!ignored.has(`${abs}\0${start}\0${end}`)) { + route.locations.push({ file, ...lineColumn(source, start) }); + route.blockers.push({ + code: "unsupported-source", + file, + message: "A remaining partialHeadings property requires manual migration.", + }); + } + } + } +} + +function findPartialHeadingsProperties(sourceFile: ts.SourceFile): Array { + const properties: Array = []; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) { + const name = node.name; + if ( + ((ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && name.text === "partialHeadings") || + (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression) && name.expression.text === "partialHeadings") + ) properties.push(node); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return properties; +} + +function canonicalRouteBlocker( + call: ts.CallExpression, + sourceFile: ts.SourceFile, + file: string, +): MigrationBlocker | null { + if (!isTopLevelCall(call, sourceFile)) { + return { code: "captured-binding", file, message: "Nested or indirect getDocsPageProps calls require manual migration." }; + } + if (call.questionDotToken || call.arguments.some((argument) => ts.isSpreadElement(argument)) || call.arguments.length !== 2 || !ts.isIdentifier(unwrapParentheses(call.arguments[0]!)) || (unwrapParentheses(call.arguments[0]!) as ts.Identifier).text !== "Astro") { + return { code: "unsupported-source", file, message: "The route call is not the canonical getDocsPageProps(Astro, options) shape." }; + } + const betweenArguments = sourceFile.text.slice(call.arguments[0]!.getEnd(), call.arguments[1]!.getStart(sourceFile)); + if (/\/[*/]/.test(betweenArguments)) { + return { code: "unsupported-source", file, message: "Comments between route arguments require manual migration." }; + } + return legacyResolverBlocker(unwrapParentheses(call.arguments[1]!), sourceFile, file); +} + +function legacyResolverBlocker(expression: ts.Expression, sourceFile: ts.SourceFile, file: string): MigrationBlocker | null { + if (!ts.isObjectLiteralExpression(expression) || hasDynamicOrDuplicateProperties(expression)) { + return { code: "unsupported-source", file, message: "The route options are not the supported literal partialHeadings shape." }; + } + const partial = property(expression, "partialHeadings"); + if (expression.properties.length !== 1 || !partial) { + return { code: "unsupported-source", file, message: "The route options contain additional or noncanonical behavior." }; + } + const partialObject = unwrapParentheses(partial.initializer); + if (!ts.isObjectLiteralExpression(partialObject) || hasDynamicOrDuplicateProperties(partialObject)) { + return { code: "unsupported-source", file, message: "partialHeadings is not a literal object." }; + } + const resolver = property(partialObject, "resolvePartialId"); + if (partialObject.properties.length !== 1 || !resolver || !isKnownFileProductResolver(unwrapParentheses(resolver.initializer), sourceFile)) { + return { code: "captured-binding", file, message: "The partial resolver is customized or captures behavior Nimbus cannot move safely." }; + } + return null; +} + +function isKnownFileProductResolver(expression: ts.Expression, sourceFile: ts.SourceFile): boolean { + if (!ts.isArrowFunction(expression) || expression.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)) return false; + if (expression.parameters.length !== 1) return false; + const parameter = expression.parameters[0]!; + if (parameter.dotDotDotToken || parameter.questionToken || parameter.initializer || parameter.type || parameter.modifiers?.length) return false; + if (!ts.isObjectBindingPattern(parameter.name) || parameter.name.elements.length !== 2) return false; + const names = parameter.name.elements.map((item) => + !item.dotDotDotToken && !item.initializer && ts.isIdentifier(item.name) && !item.propertyName + ? item.name.text + : "", + ); + if (names[0] !== "file" || names[1] !== "product") return false; + let returned: ts.Expression; + if (!ts.isBlock(expression.body)) { + returned = expression.body; + } else if (expression.body.statements.length === 1) { + const statement = expression.body.statements[0]; + if (!statement || !ts.isReturnStatement(statement) || !statement.expression) return false; + returned = statement.expression; + } else { + if (expression.body.statements.length !== 2) return false; + const [guard, statement] = expression.body.statements; + if (!guard || !statement || !ts.isIfStatement(guard) || guard.elseStatement) return false; + const condition = unwrapParentheses(guard.expression); + if (!ts.isPrefixUnaryExpression(condition) || condition.operator !== ts.SyntaxKind.ExclamationToken) return false; + if (!ts.isIdentifier(unwrapParentheses(condition.operand)) || (unwrapParentheses(condition.operand) as ts.Identifier).text !== "file") return false; + const thenStatement = guard.thenStatement; + const guardReturn = ts.isBlock(thenStatement) && thenStatement.statements.length === 1 + ? thenStatement.statements[0] + : thenStatement; + if (!guardReturn || !ts.isReturnStatement(guardReturn) || !guardReturn.expression) return false; + if (!ts.isIdentifier(unwrapParentheses(guardReturn.expression)) || (unwrapParentheses(guardReturn.expression) as ts.Identifier).text !== "undefined") return false; + if (hasOtherDeclaration(sourceFile, "undefined", expression)) return false; + if (!ts.isReturnStatement(statement) || !statement.expression) return false; + returned = statement.expression; + } + const ternary = unwrapParentheses(returned); + if (!ts.isConditionalExpression(ternary)) return false; + if (!ts.isIdentifier(unwrapParentheses(ternary.condition)) || (unwrapParentheses(ternary.condition) as ts.Identifier).text !== "product") return false; + if (!ts.isIdentifier(unwrapParentheses(ternary.whenFalse)) || (unwrapParentheses(ternary.whenFalse) as ts.Identifier).text !== "file") return false; + const template = unwrapParentheses(ternary.whenTrue); + return ts.isTemplateExpression(template) && + template.head.text === "" && + template.templateSpans.length === 2 && + ts.isIdentifier(unwrapParentheses(template.templateSpans[0]!.expression)) && + (unwrapParentheses(template.templateSpans[0]!.expression) as ts.Identifier).text === "product" && + template.templateSpans[0]!.literal.text === "/" && + ts.isIdentifier(unwrapParentheses(template.templateSpans[1]!.expression)) && + (unwrapParentheses(template.templateSpans[1]!.expression) as ts.Identifier).text === "file" && + template.templateSpans[1]!.literal.text === ""; +} + +function planConfigEdit(projectRoot: string): ConfigAnalysis { + const analyses: ConfigAnalysis[] = []; + for (const name of ASTRO_CONFIGS) { + const absoluteFile = path.join(projectRoot, name); + if (!fs.existsSync(absoluteFile)) continue; + const file = relativeFile(projectRoot, absoluteFile); + const source = fs.readFileSync(absoluteFile, "utf8"); + if (!isConfigCandidate(source)) continue; + if (fs.lstatSync(absoluteFile).isSymbolicLink()) { + analyses.push({ + locations: [{ file, line: 1, column: 1 }], + blocker: { code: "symlink-escape", file, message: "The Astro config is a symlink." }, + }); + continue; + } + const parsed = parseSource(absoluteFile, source); + if (parsed.error) { + analyses.push({ + locations: [{ file, ...diagnosticLocation(source, parsed.diagnosticStart) }], + blocker: { code: "parse-error", file, message: parsed.error }, + }); + continue; + } + if (!hasNimbusDefaultImport(parsed.file)) continue; + analyses.push(analyzeConfig(absoluteFile, file, source, parsed.file)); + } + if (analyses.length === 0) { + return { blocker: { code: "dynamic-config", message: "No canonical Nimbus integration config was found." } }; + } + const blockers = analyses.filter((analysis) => analysis.blocker); + const writable = analyses.filter((analysis) => analysis.change); + const locations = analyses.flatMap((analysis) => analysis.locations ?? []); + if (blockers.length > 0) return { ...blockers[0]!, locations }; + if (writable.length !== 1) { + return { + locations, + blocker: { code: "dynamic-config", message: `Expected one canonical Nimbus integration call, found ${writable.length}.` }, + }; + } + return writable[0]!; +} + +function analyzeConfig( + absoluteFile: string, + file: string, + source: string, + sourceFile: ts.SourceFile, +): ConfigAnalysis { + const defaultImports = sourceFile.statements.filter( + (statement): statement is ts.ImportDeclaration => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === PACKAGE_ROOT && + statement.importClause?.name?.text === "nimbus", + ); + const location = defaultImports[0] + ? locationForNode(file, source, defaultImports[0], sourceFile, 0) + : { file, line: 1, column: 1 }; + if (defaultImports.length !== 1 || hasOtherDeclaration(sourceFile, "nimbus", defaultImports[0]!.importClause!)) { + return { locations: [location], blocker: { code: "dynamic-config", file, message: "The config does not have one unshadowed default import named nimbus." } }; + } + const configObject = astroConfigObject(sourceFile); + if (!configObject || hasDynamicOrDuplicateProperties(configObject, true)) { + return { locations: [location], blocker: { code: "dynamic-config", file, message: "The default Astro config is not a canonical literal defineConfig call." } }; + } + const integrationsMember = configObject.properties.find( + (item) => propertyName(item.name) === "integrations", + ); + const integrations = property(configObject, "integrations"); + const array = integrations && unwrapParentheses(integrations.initializer); + if (!integrationsMember) { + return { locations: [location], blocker: { code: "dynamic-config", file, message: "The Astro config does not define an integrations option." } }; + } + if (!integrations || !array || !ts.isArrayLiteralExpression(array)) { + const integrationsLocation = locationForNode( + file, + source, + integrationsMember, + sourceFile, + 0, + ); + return { + locations: [location, integrationsLocation], + blocker: { + code: "dynamic-config", + file, + message: "The Astro integrations option references an indirect value; inline its literal array before running the migration.", + }, + }; + } + if (array.elements.some((element) => ts.isSpreadElement(element))) { + return { locations: [location], blocker: { code: "dynamic-config", file, message: "The Astro integrations array contains a spread." } }; + } + const calls = array.elements.filter( + (element): element is ts.CallExpression => + ts.isCallExpression(unwrapParentheses(element)) && + ts.isIdentifier(unwrapParentheses((unwrapParentheses(element) as ts.CallExpression).expression)) && + (unwrapParentheses((unwrapParentheses(element) as ts.CallExpression).expression) as ts.Identifier).text === "nimbus", + ).map((element) => unwrapParentheses(element) as ts.CallExpression); + if (calls.length !== 1) { + return { locations: [location], blocker: { code: "dynamic-config", file, message: `Expected one direct nimbus call in integrations, found ${calls.length}.` } }; + } + const call = calls[0]!; + const callLocation = locationForNode(file, source, call, sourceFile, 0); + const allCalls = findIdentifierCalls(sourceFile, "nimbus"); + if (allCalls.length !== 1 || allCalls[0] !== call || hasIndirectReference(sourceFile, "nimbus", defaultImports[0]!.importClause!)) { + return { locations: [location, callLocation], blocker: { code: "dynamic-config", file, message: "The nimbus binding has nested or indirect uses." } }; + } + if (call.questionDotToken || call.arguments.some((argument) => ts.isSpreadElement(argument)) || call.arguments.length < 1 || call.arguments.length > 2) { + return { locations: [location, callLocation], blocker: { code: "dynamic-config", file, message: "The Nimbus integration call has an unsupported argument shape." } }; + } + const eol = source.includes("\r\n") ? "\r\n" : "\n"; + const unit = indentationUnit(source); + let after: string; + if (call.arguments.length === 1) { + const indent = lineIndent(source, call.getStart(sourceFile)); + const insertAt = call.arguments[0]!.getEnd(); + after = source.slice(0, insertAt) + `, ${resolverOptions(indent, unit, eol)}` + source.slice(insertAt); + } else { + const options = unwrapParentheses(call.arguments[1]!); + if (!ts.isObjectLiteralExpression(options) || hasDynamicOrDuplicateProperties(options)) { + return { locations: [location, callLocation], blocker: { code: "dynamic-config", file, message: "Nimbus integration options are not a spread-free literal object." } }; + } + const markdown = property(options, "markdown"); + if (!markdown) { + const insertion = objectInsertion(source, sourceFile, options, `markdown: ${resolverMarkdown("", unit, eol)},`, unit, eol); + after = source.slice(0, insertion.offset) + insertion.text + source.slice(insertion.offset); + } else { + const markdownObject = unwrapParentheses(markdown.initializer); + if (!ts.isObjectLiteralExpression(markdownObject) || hasDynamicOrDuplicateProperties(markdownObject)) { + return { locations: [location, callLocation], blocker: { code: "dynamic-config", file, message: "markdown options are not a spread-free literal object." } }; + } + if (property(markdownObject, "partialResolver")) { + return { locations: [location, callLocation], blocker: { code: "config-conflict", file, message: "markdown.partialResolver already exists with project-owned behavior." } }; + } + const insertion = objectInsertion(source, sourceFile, markdownObject, `${resolverProperty("", unit, eol)},`, unit, eol); + after = source.slice(0, insertion.offset) + insertion.text + source.slice(insertion.offset); + } + } + return { + locations: [location, callLocation], + change: { file, absoluteFile, before: source, after, operation: "update" }, + }; +} + +function astroConfigObject(sourceFile: ts.SourceFile): ts.ObjectLiteralExpression | null { + const defineConfigImports = getNamedImports(sourceFile, "astro/config", "defineConfig").filter( + (item) => !item.propertyName && item.name.text === "defineConfig", + ); + if (defineConfigImports.length !== 1 || hasOtherDeclaration(sourceFile, "defineConfig", defineConfigImports[0]!)) return null; + const exports = sourceFile.statements.filter( + (statement): statement is ts.ExportAssignment => ts.isExportAssignment(statement) && !statement.isExportEquals, + ); + if (exports.length !== 1) return null; + const expression = unwrapParentheses(exports[0]!.expression); + if (!ts.isCallExpression(expression) || expression.questionDotToken || expression.arguments.some((argument) => ts.isSpreadElement(argument)) || expression.arguments.length !== 1) return null; + const callee = unwrapParentheses(expression.expression); + const argument = unwrapParentheses(expression.arguments[0]!); + return ts.isIdentifier(callee) && callee.text === "defineConfig" && ts.isObjectLiteralExpression(argument) + ? argument + : null; +} + +function getNamedImports(sourceFile: ts.SourceFile, moduleName: string, importedName: string): ts.ImportSpecifier[] { + const imports: ts.ImportSpecifier[] = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleName) continue; + const named = statement.importClause?.namedBindings; + if (!named || !ts.isNamedImports(named)) continue; + for (const item of named.elements) { + if ((item.propertyName?.text ?? item.name.text) === importedName) imports.push(item); + } + } + return imports; +} + +function findDirectProseCalls(sourceFile: ts.SourceFile): ts.CallExpression[] { + const calls = PROSE_HELPERS.flatMap((helper) => + ROUTE_ENTRYPOINTS.flatMap((moduleName) => + getNamedImports(sourceFile, moduleName, helper).flatMap((item) => + findIdentifierCalls(sourceFile, item.name.text) + ) + ) + ); + const namespaces = new Set( + sourceFile.statements.flatMap((statement) => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + ROUTE_ENTRYPOINTS.includes(statement.moduleSpecifier.text as (typeof ROUTE_ENTRYPOINTS)[number]) && + statement.importClause?.namedBindings && + ts.isNamespaceImport(statement.importClause.namedBindings) + ? [statement.importClause.namedBindings.name.text] + : [] + ), + ); + if (namespaces.size === 0) return calls; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = unwrapParentheses(node.expression); + const helper = ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) + ? callee.name.text + : ts.isElementAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.argumentExpression && ts.isStringLiteralLike(callee.argumentExpression) + ? callee.argumentExpression.text + : null; + const receiver = ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee) + ? callee.expression + : null; + if ( + helper && + receiver && + ts.isIdentifier(receiver) && + namespaces.has(receiver.text) && + PROSE_HELPERS.includes(helper as (typeof PROSE_HELPERS)[number]) + ) calls.push(node); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return calls; +} + +function hasNimbusDefaultImport(sourceFile: ts.SourceFile): boolean { + return sourceFile.statements.some( + (statement) => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === PACKAGE_ROOT && + statement.importClause?.name?.text === "nimbus", + ); +} + +function findIdentifierCalls(sourceFile: ts.SourceFile, name: string): ts.CallExpression[] { + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = unwrapParentheses(node.expression); + if (ts.isIdentifier(callee) && callee.text === name) calls.push(node); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return calls; +} + +function isPotentiallyLegacyCall(call: ts.CallExpression): boolean { + if (call.arguments.length >= 2) return true; + const only = call.arguments[0]; + if (!only || !ts.isSpreadElement(only)) return false; + const spread = unwrapParentheses(only.expression); + return !ts.isArrayLiteralExpression(spread) || spread.elements.length >= 2; +} + +function hasIndirectReference(sourceFile: ts.SourceFile, name: string, allowedImport: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found || node === allowedImport) return; + if (ts.isIdentifier(node) && node.text === name && !isNonReferencePropertyName(node)) { + let current: ts.Node = node; + while (current.parent && ts.isParenthesizedExpression(current.parent)) current = current.parent; + if (!ts.isCallExpression(current.parent) || current.parent.expression !== current) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +function isNonReferencePropertyName(identifier: ts.Identifier): boolean { + const parent = identifier.parent; + return (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isPropertyAssignment(parent) && parent.name === identifier) || + (ts.isMethodDeclaration(parent) && parent.name === identifier) || + (ts.isPropertyDeclaration(parent) && parent.name === identifier); +} + +function hasOtherDeclaration(sourceFile: ts.SourceFile, name: string, allowed: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found || node === allowed) return; + if ( + (ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) && + bindingContains(node.name, name) + ) { + found = true; + return; + } + if ( + (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isClassDeclaration(node)) && + node.name?.text === name + ) { + found = true; + return; + } + if ( + (ts.isImportClause(node) && node.name?.text === name) || + (ts.isImportSpecifier(node) && node.name.text === name) || + (ts.isNamespaceImport(node) && node.name.text === name) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +function bindingContains(binding: ts.BindingName, name: string): boolean { + if (ts.isIdentifier(binding)) return binding.text === name; + return binding.elements.some((element) => !ts.isOmittedExpression(element) && bindingContains(element.name, name)); +} + +function isTopLevelCall(call: ts.CallExpression, sourceFile: ts.SourceFile): boolean { + let current: ts.Node = call; + while ( + current.parent && + (ts.isAwaitExpression(current.parent) || ts.isParenthesizedExpression(current.parent)) + ) { + current = current.parent; + } + if (ts.isExpressionStatement(current.parent)) return current.parent.parent === sourceFile; + if (!ts.isVariableDeclaration(current.parent) || current.parent.initializer !== current) return false; + const declarationList = current.parent.parent; + return ts.isVariableDeclarationList(declarationList) && ts.isVariableStatement(declarationList.parent) && declarationList.parent.parent === sourceFile; +} + +function removeLegacyArgument(call: LegacyCall): string { + const first = call.call.arguments[0]!; + return call.source.slice(0, first.getEnd() + call.scriptOffset) + call.source.slice(call.call.arguments.end + call.scriptOffset); +} + +function validatePostimage(change: MigrationChange): string | null { + const source = change.file.endsWith(".astro") + ? extractAstroFrontmatter(change.after)?.script + : change.after; + if (source === undefined) return `Could not parse transformed ${change.file}: incomplete Astro frontmatter.`; + return parseSource(change.file, source).error ?? null; +} + +function objectInsertion( + source: string, + file: ts.SourceFile, + object: ts.ObjectLiteralExpression, + propertyText: string, + unit: string, + eol: string, +): { offset: number; text: string } { + const offset = object.getStart(file) + 1; + const indent = lineIndent(source, object.getStart(file)) + unit; + const text = propertyText.split(/\r?\n/).map((line) => indent + line).join(eol); + return { offset, text: `${eol}${text}` }; +} + +function resolverOptions(indent: string, unit: string, eol: string): string { + const child = indent + unit; + return ["{", `${child}markdown: ${resolverMarkdown(child, unit, eol)},`, `${indent}}`].join(eol); +} + +function resolverMarkdown(indent: string, unit: string, eol: string): string { + const child = indent + unit; + return ["{", `${child}${resolverProperty(child, unit, eol)},`, `${indent}}`].join(eol); +} + +function resolverProperty(indent: string, unit: string, eol: string): string { + const child = indent + unit; + const grandchild = child + unit; + return [ + "partialResolver: {", + `${child}revision: "partial-resolver-v1",`, + `${child}resolve: ({ file, product }) =>`, + `${grandchild}product ? \`${"${product}"}/${"${file}"}\` : file,`, + `${indent}}`, + ].join(eol); +} + +function property(object: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | null { + for (const item of object.properties) { + if (!ts.isPropertyAssignment(item) || !item.name || ts.isComputedPropertyName(item.name)) continue; + if ((ts.isIdentifier(item.name) || ts.isStringLiteralLike(item.name)) && item.name.text === name) return item; + } + return null; +} + +function propertyName(name: ts.PropertyName | undefined): string | null { + if (!name || ts.isComputedPropertyName(name)) return null; + return ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : null; +} + +function hasDynamicOrDuplicateProperties( + object: ts.ObjectLiteralExpression, + allowShorthand = false, +): boolean { + const names = new Set(); + for (const item of object.properties) { + if ( + !ts.isPropertyAssignment(item) && + !(allowShorthand && ts.isShorthandPropertyAssignment(item)) + ) { + return true; + } + const name = propertyName(item.name); + if (name === null || names.has(name)) return true; + names.add(name); + } + return false; +} + +function unwrapParentheses(expression: T): ts.Expression { + let current: ts.Expression = expression; + while (ts.isParenthesizedExpression(current)) current = current.expression; + return current; +} + +function parseSource(file: string, source: string): { + file: ts.SourceFile; + error?: string; + diagnosticStart?: number; +} { + const extension = path.extname(file); + const kind = extension === ".js" || extension === ".mjs" || extension === ".cjs" + ? ts.ScriptKind.JS + : extension === ".jsx" + ? ts.ScriptKind.JSX + : extension === ".tsx" + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS; + const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); + const parseDiagnostics = (parsed as ts.SourceFile & { parseDiagnostics?: readonly ts.Diagnostic[] }).parseDiagnostics; + const jsDiagnostics = kind === ts.ScriptKind.JS || kind === ts.ScriptKind.JSX + ? ts.transpileModule(source, { + fileName: file, + reportDiagnostics: true, + compilerOptions: { allowJs: true, checkJs: true, target: ts.ScriptTarget.Latest }, + }).diagnostics + : undefined; + const first = parseDiagnostics?.[0] ?? jsDiagnostics?.[0]; + return first + ? { + file: parsed, + error: `Could not parse ${path.basename(file)}: ${ts.flattenDiagnosticMessageText(first.messageText, " ")}`, + diagnosticStart: first.start, + } + : { file: parsed }; +} + +function extractAstroFrontmatter(source: string): { script: string; offset: number } | null { + const opening = /^\uFEFF?[ \t]*(?:\r?\n[ \t]*)*---[ \t]*\r?\n/.exec(source); + if (!opening) return null; + const start = opening[0].length; + const closing = /(?:^|\r?\n)---[ \t]*(?:\r?\n|$)/gm; + closing.lastIndex = start; + const match = closing.exec(source); + if (!match) return null; + const end = match.index + (match[0].startsWith("\n") || match[0].startsWith("\r") ? 1 : 0); + return { script: source.slice(start, end), offset: start }; +} + +function routeSymlinks(root: string): string[] { + const symlinks: string[] = []; + try { + if (fs.lstatSync(root).isSymbolicLink()) return [root]; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const visit = (directory: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + for (const entry of entries) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + let directoryTarget = false; + try { + directoryTarget = fs.statSync(absolute).isDirectory(); + } catch { + directoryTarget = true; + } + if (directoryTarget || path.extname(entry.name) === ".astro") symlinks.push(absolute); + } else if (entry.isDirectory() && entry.name !== "node_modules") { + visit(absolute); + } + } + }; + visit(root); + return symlinks.sort((a, b) => a.localeCompare(b)); +} + +function isConfigCandidate(source: string): boolean { + return source.includes("nimbus") && source.includes(PACKAGE_ROOT); +} + +function locationForNode( + file: string, + source: string, + node: ts.Node, + sourceFile: ts.SourceFile, + offset: number, +): MigrationLocation { + return { file, ...lineColumn(source, node.getStart(sourceFile) + offset) }; +} + +function diagnosticLocation(source: string, start = 0, offset = 0): { line: number; column: number } { + return lineColumn(source, start + offset); +} + +function lineColumn(source: string, offset: number): { line: number; column: number } { + const lines = source.slice(0, offset).split(/\r?\n/); + return { line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 }; +} + +function lineIndent(source: string, offset: number): string { + const start = source.lastIndexOf("\n", offset - 1) + 1; + return /^[ \t]*/.exec(source.slice(start, offset))?.[0] ?? ""; +} + +function indentationUnit(source: string): string { + return /\n\t+\S/.test(source) ? "\t" : " "; +} + +function dedupeLocations(locations: MigrationLocation[]): MigrationLocation[] { + const seen = new Set(); + return locations.filter((location) => { + const key = `${location.file}:${location.line}:${location.column}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function dedupeBlockers(blockers: MigrationBlocker[]): MigrationBlocker[] { + const seen = new Set(); + return blockers + .filter((blocker) => { + const key = `${blocker.code}:${blocker.file ?? ""}:${blocker.message}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => (a.file ?? "").localeCompare(b.file ?? "") || a.code.localeCompare(b.code) || a.message.localeCompare(b.message)); +} + +function relativeFile(root: string, file: string): string { + return path.relative(root, file).split(path.sep).join("/"); +} + +function isInside(root: string, target: string): boolean { + const rel = path.relative(root, target); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +function validateExistingPath(root: string, target: string): string | undefined { + try { + const realRoot = fs.realpathSync(root); + const realTarget = fs.realpathSync(target); + if (!isInside(realRoot, realTarget)) return `Path resolves outside the selected project: ${target}.`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return errorMessage(error); + } + return undefined; +} + +function locationOrder(a: MigrationLocation, b: MigrationLocation): number { + return a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts b/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts index 29961ab1..505efe0a 100644 --- a/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts +++ b/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts @@ -7,6 +7,7 @@ import fs from "node:fs"; import path from "node:path"; +import ts from "typescript"; import { findMatchingBrace } from "./parse-object-literal.js"; @@ -73,7 +74,8 @@ export function parseNimbusConfig(cwd: string): ConfigParseResult { // blanked, offsets identical); values are read from `source`. const masked = maskSource(source); - const local = findDefaultImportName(source, masked, NIMBUS_PACKAGE); + const parsed = parseNimbusCall(file, source); + const local = parsed.local; if (!local) { return { ok: false, @@ -82,9 +84,16 @@ export function parseNimbusConfig(cwd: string): ConfigParseResult { file, }; } + if (parsed.ambiguous) { + return { + ok: false, + reason: "no-object", + detail: `${path.basename(file)} has multiple default Nimbus imports or integration calls. Keep one unambiguous \`${local}(config)\` call for static checks.`, + file, + }; + } - const argText = findFirstCallArg(masked, local); - if (!argText) { + if (!parsed.argument) { return { ok: false, reason: "no-call", @@ -93,7 +102,7 @@ export function parseNimbusConfig(cwd: string): ConfigParseResult { }; } - const objectStart = locateConfigObject(masked, argText); + const objectStart = parsed.objectStart; if (objectStart === -1) { return { ok: false, @@ -126,12 +135,20 @@ export function parseNimbusConfig(cwd: string): ConfigParseResult { * Length-preserving copy with comments AND string interiors blanked. String * awareness is load-bearing: the shared `stripComments` would treat the `//` * in `site: "https://example.com"` as a comment and corrupt the literal. - * Regex literals aren't distinguished from division (rare in config); a - * mis-mask degrades the read, never a false "valid", and can't corrupt a - * `--fix` write (`rewriteConfigField` re-verifies the span). + * TypeScript's scanner identifies regex literals before this lightweight + * pass so quotes inside them cannot hide later config structure. */ function maskSource(source: string): string { const out = source.split(""); + const regexEnds = new Map(); + const sourceFile = ts.createSourceFile("astro.config.ts", source, ts.ScriptTarget.Latest, true); + const collectRegex = (node: ts.Node): void => { + if (ts.isRegularExpressionLiteral(node)) { + regexEnds.set(node.getStart(sourceFile), node.getEnd()); + } + ts.forEachChild(node, collectRegex); + }; + collectRegex(sourceFile); let inString: string | null = null; for (let i = 0; i < source.length; i++) { const ch = source[i]; @@ -148,6 +165,14 @@ function maskSource(source: string): string { if (ch !== "\n") out[i] = " "; continue; } + const regexEnd = regexEnds.get(i); + if (regexEnd !== undefined) { + for (let j = i; j < regexEnd; j++) { + if (source[j] !== "\n") out[j] = " "; + } + i = regexEnd - 1; + continue; + } if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; @@ -192,132 +217,155 @@ function findConfigFile(cwd: string): { file: string; source: string } | null { return null; } -// Anchor on `from "pkg"` then walk back to the nearest `import` — robust to -// semicolonless code, and the exact-quote match ignores subpath specifiers. -// The `from` regex runs over raw `source` (masked blanks the package string); -// the `import` anchor uses `masked`, so a `from "pkg"` inside a comment/string -// has no real preceding import and is skipped. -function findDefaultImportName(source: string, masked: string, pkg: string): string | null { - const safePkg = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const fromRe = new RegExp(`from\\s+(["'])${safePkg}\\1`, "g"); - const importPositions = [...masked.matchAll(/\bimport\b/g)].map((m) => m.index!); - - let match: RegExpExecArray | null; - while ((match = fromRe.exec(source)) !== null) { - let importIdx = -1; - for (const idx of importPositions) { - if (idx < match.index) importIdx = idx; - else break; +function parseNimbusCall(file: string, source: string): { + local: string | null; + argument: { start: number; end: number } | null; + objectStart: number; + ambiguous: boolean; +} { + const options: ts.CompilerOptions = { allowJs: true, noResolve: true, target: ts.ScriptTarget.Latest }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + const selectedFile = path.resolve(file); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => + path.resolve(fileName) === selectedFile + ? ts.createSourceFile(file, source, languageVersion, true, configScriptKind(file)) + : getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); + const program = ts.createProgram({ rootNames: [file], options, host }); + const sourceFile = program.getSourceFile(file) ?? ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const checker = program.getTypeChecker(); + const importBindings: ts.Identifier[] = []; + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== NIMBUS_PACKAGE || + statement.importClause?.isTypeOnly + ) continue; + if (statement.importClause?.name) { + importBindings.push(statement.importClause.name); + continue; } - if (importIdx === -1) continue; - - const clause = masked.slice(importIdx + "import".length, match.index).trim(); - if (/\bimport\b/.test(clause)) continue; - - const explicit = clause.match(/\bdefault\s+as\s+([A-Za-z_$][\w$]*)/); - if (explicit) return explicit[1]!; - - const beforeBrace = clause.split(/[{*]/)[0]!.trim().replace(/,\s*$/, ""); - if (/^[A-Za-z_$][\w$]*$/.test(beforeBrace)) return beforeBrace; - } - return null; -} - -function findFirstCallArg(masked: string, name: string): string | null { - const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const callRe = new RegExp(`\\b${safeName}\\s*\\(`, "g"); - let match: RegExpExecArray | null; - while ((match = callRe.exec(masked)) !== null) { - if (masked[match.index - 1] === ".") continue; // skip `x.nimbus(...)` - const arg = captureFirstArg(masked, match.index + match[0].length - 1); - if (arg) return arg; // non-empty only: `nimbus()` → "" is not a config - } - return null; -} - -function captureFirstArg(input: string, openParen: number): string | null { - let depth = 0; - let inString: string | null = null; - const start = openParen + 1; - for (let i = start; i < input.length; i++) { - const ch = input[i]; - if (inString) { - if (ch === "\\") { - i++; - continue; + const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + const item = bindings.elements.find((element) => element.propertyName?.text === "default" && !element.isTypeOnly); + if (item) { + importBindings.push(item.name); } - if (ch === inString) inString = null; - continue; } - if (ch === '"' || ch === "'" || ch === "`") inString = ch; - else if (ch === "(" || ch === "{" || ch === "[") depth++; - else if (ch === ")" || ch === "}" || ch === "]") { - if (depth === 0) return input.slice(start, i).trim(); - depth--; - } else if (ch === "," && depth === 0) return input.slice(start, i).trim(); } - return null; + const importBinding = importBindings[0]; + const local = importBinding?.text ?? null; + if (!local || !importBinding) return { local: null, argument: null, objectStart: -1, ambiguous: false }; + if (importBindings.length !== 1) return { local, argument: null, objectStart: -1, ambiguous: true }; + + const importSymbol = checker.getSymbolAtLocation(importBinding); + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + if ( + ts.isIdentifier(callee) && + callee.text === local && + checker.getSymbolAtLocation(callee) === importSymbol + ) { + calls.push(node); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + const call = calls.length === 1 && calls[0]!.arguments[0] ? calls[0]! : null; + if (!call) return { + local, + argument: calls[0]?.arguments[0] ? nodeSpan(calls[0].arguments[0]!, sourceFile) : null, + objectStart: -1, + ambiguous: calls.length > 1, + }; + const argument = call.arguments[0]!; + return { + local, + argument: nodeSpan(argument, sourceFile), + objectStart: configObjectExpression(argument, checker)?.getStart(sourceFile) ?? -1, + ambiguous: false, + }; } -function locateConfigObject(masked: string, argText: string): number { - const arg = argText.trim(); - if (/^[A-Za-z_$][\w$]*$/.test(arg)) { - const declValue = findDeclarationValueOffset(masked, arg); - return declValue === -1 ? -1 : resolveObjectBrace(masked, declValue); - } - const argStart = masked.indexOf(arg); - return argStart === -1 ? -1 : resolveObjectBrace(masked, argStart); +function configScriptKind(file: string): ts.ScriptKind { + return /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS; } -// Supports only `{ … }` and single-argument `defineNimbusConfig({ … })`. A -// multi-arg call is rejected (we can't know which arg is the config) → -// `no-object`, never a wrong read. -function resolveObjectBrace(masked: string, from: number): number { - let i = skipWs(masked, from); - if (masked[i] === "{") return i; - - const idMatch = /^[A-Za-z_$][\w$]*/.exec(masked.slice(i)); - if (!idMatch) return -1; - i = skipWs(masked, i + idMatch[0].length); - if (masked[i] !== "(") return -1; - - const braceStart = skipWs(masked, i + 1); - if (masked[braceStart] !== "{") return -1; - const braceEnd = findMatchingBrace(masked, braceStart); - if (braceEnd === -1) return -1; - if (masked[skipWs(masked, braceEnd + 1)] !== ")") return -1; - return braceStart; +function nodeSpan(node: ts.Node, sourceFile: ts.SourceFile): { start: number; end: number } { + return { start: node.getStart(sourceFile), end: node.getEnd() }; } -function skipWs(input: string, from: number): number { - let i = from; - while (i < input.length && /\s/.test(input[i]!)) i++; - return i; +function configObjectExpression( + expression: ts.Expression, + checker: ts.TypeChecker, + seen = new Set(), +): ts.ObjectLiteralExpression | null { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) + ) current = current.expression; + if (ts.isObjectLiteralExpression(current)) return current; + if (ts.isCallExpression(current)) { + return ts.isIdentifier(current.expression) && + isNimbusConfigWrapper(current.expression, checker) && + current.arguments.length === 1 + ? configObjectExpression(current.arguments[0]!, checker, seen) + : null; + } + if (!ts.isIdentifier(current)) return null; + const symbol = checker.getSymbolAtLocation(current); + if (!symbol || seen.has(symbol)) return null; + seen.add(symbol); + const declarations = symbol.declarations ?? []; + const declaration = declarations[0]; + if ( + declarations.length !== 1 || + !declaration || + !ts.isVariableDeclaration(declaration) || + !declaration.initializer || + !ts.isVariableDeclarationList(declaration.parent) || + !(declaration.parent.flags & ts.NodeFlags.Const) || + hasOtherSymbolReference(declaration.getSourceFile(), checker, symbol, declaration.name, current) + ) return null; + return configObjectExpression(declaration.initializer, checker, seen); } -function findDeclarationValueOffset(masked: string, identifier: string): number { - const safeId = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const declRe = new RegExp(`\\b(?:const|let|var)\\s+${safeId}\\b`, "g"); - let match: RegExpExecArray | null; - while ((match = declRe.exec(masked)) !== null) { - const eqIdx = findAssignmentEquals(masked, match.index + match[0].length); - if (eqIdx !== -1) return eqIdx + 1; - } - return -1; +function isNimbusConfigWrapper(identifier: ts.Identifier, checker: ts.TypeChecker): boolean { + const declarations = checker.getSymbolAtLocation(identifier)?.declarations ?? []; + const imported = declarations[0]; + if (declarations.length !== 1 || !imported || !ts.isImportSpecifier(imported)) return false; + const declaration = imported.parent.parent.parent; + return (imported.propertyName?.text ?? imported.name.text) === "defineConfig" && + ts.isImportDeclaration(declaration) && + ts.isStringLiteral(declaration.moduleSpecifier) && + declaration.moduleSpecifier.text === NIMBUS_PACKAGE; } -// First `=` that's an assignment (skips `==`, `===`, `=>`, `<=`, `>=`, `!=`). -function findAssignmentEquals(source: string, from: number): number { - for (let i = from; i < source.length; i++) { - if (source[i] !== "=") continue; - if (source[i + 1] === "=" || source[i + 1] === ">") { - i++; - continue; +function hasOtherSymbolReference( + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, + symbol: ts.Symbol, + declarationName: ts.BindingName, + allowedReference: ts.Identifier, +): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found || node === declarationName || node === allowedReference) return; + if (ts.isIdentifier(node) && checker.getSymbolAtLocation(node) === symbol) { + found = true; + return; } - if (source[i - 1] === "!" || source[i - 1] === "<" || source[i - 1] === ">") continue; - return i; - } - return -1; + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; } interface FieldRead { diff --git a/packages/nimbus-docs/src/_internal/upgrade-manifest.json b/packages/nimbus-docs/src/_internal/upgrade-manifest.json new file mode 100644 index 00000000..469e607a --- /dev/null +++ b/packages/nimbus-docs/src/_internal/upgrade-manifest.json @@ -0,0 +1,164 @@ +{ + "schemaVersion": 1, + "oldestSupportedVersion": "0.11.0", + "entries": [ + { + "id": "remove-gated-config", + "introducedIn": "0.12.0", + "mode": "review-required", + "changeset": "calm-upgrades-plan", + "summary": "Remove the withdrawn gated configuration option.", + "affected": "Projects using the gated Nimbus config option as an access-control or publication boundary.", + "instructions": [ + "Remove gated from the Nimbus configuration.", + "Move content that must not be published out of every routed content collection.", + "Do not substitute noindex for access control; noindex affects discovery, not confidentiality." + ], + "verify": [ + "Inspect the built route inventory and confirm private content is absent.", + "Run nimbus-docs check and the production build." + ] + }, + { + "id": "partial-resolver-to-markdown", + "introducedIn": "0.13.0", + "mode": "automatic", + "migrationId": "partial-resolver-to-markdown", + "summary": "Move route-level partial heading resolution into markdown.partialResolver.", + "affected": "Projects passing partialHeadings.resolvePartialId to prose helpers or importing the removed PartialHeadingOptions type.", + "instructions": [ + "Run nimbus-docs migrate to apply the canonical getDocsPageProps transform where available.", + "Review every getDocsPageProps, getDocsPage, getCollectionPageProps, and getCollectionPage call that passes partialHeadings.", + "Move custom resolver behavior to markdown.partialResolver in the Nimbus integration as { revision: \"partial-resolver-v1\", resolve: ({ file, product }) => product ? `${product}/${file}` : file }, preserving the project's exact resolver behavior, and remove the route-level option.", + "Remove PartialHeadingOptions imports and use the markdown.partialResolver callback types inferred from Nimbus config." + ], + "verify": [ + "Confirm partial headings still resolve to the same collection IDs.", + "Run nimbus-docs check, astro check, and the production build." + ] + }, + { + "id": "prepared-markdown-artifacts", + "introducedIn": "0.13.0", + "mode": "review-required", + "summary": "Move custom Markdown routes that expand Render partials to prepared build artifacts.", + "affected": "Custom routes calling renderEntryAsMarkdown or getEntryMarkdown with authored Render partials.", + "instructions": [ + "Review custom Markdown and MDX endpoints for renderEntryAsMarkdown, getEntryMarkdown, and Render partial expansion.", + "Use getMarkdownStaticPaths and getMarkdownPayload from @cloudflare/nimbus-docs/agent-endpoints for generated publication routes.", + "Read route props from props.reference and call getMarkdownPayload with { collection, surface, slug: params.slug, reference: props.reference, context: { request } }; handle a null payload before constructing the response.", + "Move custom component transforms into revisioned markdown.componentMap integration configuration." + ], + "verify": [ + "Request every custom Markdown or MDX endpoint and confirm partial content is present.", + "Run astro check and the production build." + ] + }, + { + "id": "llms-full-prepared-artifact", + "introducedIn": "0.13.0", + "mode": "review-required", + "summary": "Replace renderCorpusMarkdown with the prepared full-site LLM artifact.", + "affected": "Projects with a custom llms-full.txt route using renderCorpusMarkdown.", + "instructions": [ + "Review the project for renderCorpusMarkdown imports and calls.", + "Use getLlmsPayload({ scope: \"site\", surface: \"full\" }, { request }) from @cloudflare/nimbus-docs/agent-endpoints and handle a null payload before constructing the response.", + "If custom runtime composition is still required and contains no partials, use renderLlmsFullMarkdown." + ], + "verify": [ + "Request llms-full.txt and confirm the expected pages and expanded partial content are present.", + "Run astro check and the production build." + ] + }, + { + "id": "index-route-normalization", + "introducedIn": "0.13.0", + "mode": "review-required", + "summary": "Review routes for content entries whose IDs end in index.", + "affected": "Projects with root or nested index.md/index.mdx entries using Nimbus static-path helpers.", + "instructions": [ + "Review content entries named index.md or index.mdx and links that include a trailing /index segment.", + "Update links to the normalized directory route and add redirects for established inbound /index URLs when needed.", + "Check for collisions with explicit Astro index routes." + ], + "verify": [ + "Request each affected root and nested directory route.", + "Run nimbus-docs check and the production build." + ] + }, + { + "id": "logical-authored-links", + "introducedIn": "0.13.0", + "mode": "review-required", + "summary": "Author root-relative links as logical paths without Astro's deployment base.", + "affected": "Subpath deployments whose authored links already include the configured Astro base.", + "instructions": [ + "Review authored Markdown, MDX, and static JSX links that begin with the configured Astro base.", + "Remove the deployment base from authored root-relative destinations because Nimbus now applies it.", + "Remove noncanonical path traversal or encoded path delimiters from authored destinations." + ], + "verify": [ + "Build with the production base and check representative authored links.", + "Run nimbus-docs check and the production build." + ] + }, + { + "id": "prepared-publication-api-renames", + "introducedIn": "0.13.0", + "mode": "review-required", + "changeset": "calm-upgrades-plan", + "summary": "Replace the removed Twin and Corpus publication API names.", + "affected": "Projects importing legacy Twin or Corpus APIs, deprecated prepared helpers from @cloudflare/nimbus-docs/build or @cloudflare/nimbus-docs/publication, or renderCorpusMarkdown.", + "instructions": [ + "Import getMarkdownStaticPaths, getMarkdownPayload, getLlmsStaticPaths, getLlmsPayload, and their endpoint types from @cloudflare/nimbus-docs/agent-endpoints.", + "Map getPreparedTwinStaticPaths, getPreparedMarkdownStaticPaths, and getPreparedMarkdownRouteStaticPaths to getMarkdownStaticPaths; map getPreparedCorpusStaticPaths, getPreparedLlmsStaticPaths, and getPreparedLlmsRouteStaticPaths to getLlmsStaticPaths; update static-path props from artifact to reference.", + "Map getPreparedMarkdownRouteArtifact(options) to getMarkdownPayload(options), and map getPreparedLlmsRouteArtifact(reference, { request }) to getLlmsPayload(reference, { request }).", + "Map getPreparedTwinArtifact(reference) and getPreparedMarkdownArtifact(reference) to getMarkdownPayload({ collection: reference.collection, surface: reference.surface, reference, context: { request } }).", + "Map getPreparedCorpusArtifact(reference) and getPreparedLlmsArtifact(reference) to getLlmsPayload(reference, { request }).", + "For section llms.txt routes, preserve request-rendered fallback resolution with props.reference ?? (params.section ? { scope: \"section\", surface: \"index\", section: params.section } : null); return a 404 response when reference is null before calling getLlmsPayload(reference, { request }).", + "Map TwinSurface and PreparedMarkdownSurface to MarkdownEndpointSurface; PreparedTwinReference and PreparedMarkdownReference to MarkdownEndpointReference; PreparedTwinArtifact and PreparedMarkdownArtifact to MarkdownEndpointPayload; PreparedCorpusReference and PreparedLlmsReference to LlmsEndpointReference; and PreparedCorpusArtifact and PreparedLlmsArtifact to LlmsEndpointPayload.", + "Handle the nullable result from getMarkdownPayload and getLlmsPayload before constructing a response.", + "Use renderLlmsFullMarkdown only when custom runtime composition is still required and contains no partials.", + "Review argument and return types at every renamed call rather than applying an unbounded text replacement." + ], + "verify": [ + "Run astro check to find remaining removed imports and incompatible calls.", + "Request representative Markdown, MDX source, llms.txt, and llms-full.txt outputs." + ] + }, + { + "id": "twins-config-to-markdown", + "introducedIn": "0.13.0", + "mode": "review-required", + "changeset": "calm-upgrades-plan", + "summary": "Move twins integration customization under markdown.", + "affected": "Projects configuring twins.componentMap or twins.partialResolver in the Nimbus integration.", + "instructions": [ + "Move twins.componentMap to markdown.componentMap.", + "Move twins.partialResolver to markdown.partialResolver.", + "Preserve each revision and resolver or component mapping implementation while removing the twins object." + ], + "verify": [ + "Run astro check and confirm the Nimbus configuration validates.", + "Request generated Markdown and a page containing expanded partial content." + ] + }, + { + "id": "with-base-route-to-with-base", + "introducedIn": "0.13.0", + "mode": "review-required", + "changeset": "calm-upgrades-plan", + "summary": "Replace the removed withBaseRoute runtime helper with withBase.", + "affected": "Projects importing or calling withBaseRoute from Nimbus runtime APIs.", + "instructions": [ + "Replace withBaseRoute imports and calls with withBase.", + "Pass logical site-relative paths that do not already contain Astro's configured deployment base.", + "Review custom URL composition for double bases and path traversal." + ], + "verify": [ + "Build with the production deployment base and inspect every affected URL.", + "Run astro check and the production build." + ] + } + ] +} diff --git a/packages/nimbus-docs/src/_internal/upgrades.ts b/packages/nimbus-docs/src/_internal/upgrades.ts new file mode 100644 index 00000000..e615bc70 --- /dev/null +++ b/packages/nimbus-docs/src/_internal/upgrades.ts @@ -0,0 +1,221 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { compare, eq, gt, lt, lte, valid } from "semver"; + +import rawManifest from "./upgrade-manifest.json"; + +declare const __APP_VERSION__: string; + +export type UpgradeMode = "automatic" | "detectable-manual" | "review-required"; + +export interface UpgradeEntry { + id: string; + introducedIn: string; + mode: UpgradeMode; + migrationId?: string; + changeset?: string; + summary: string; + affected: string; + instructions: string[]; + verify: string[]; +} + +export interface UpgradeBaseline { + fromVersion: string | null; + targetVersion: string; + source: "argument" | "nimbus-json" | "missing" | "preview"; + error?: string; +} + +interface UpgradeManifest { + schemaVersion: 1; + oldestSupportedVersion: string; + entries: UpgradeEntry[]; +} + +export const UPGRADE_MANIFEST = rawManifest as UpgradeManifest; + +export function runningNimbusVersion(): string { + if (typeof __APP_VERSION__ !== "undefined") return __APP_VERSION__; + const version = (JSON.parse( + fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { version?: unknown }).version; + if (typeof version !== "string" || !valid(version)) { + throw new Error("Could not determine the executing Nimbus version."); + } + return version; +} + +export function selectUpgradeEntries(fromVersion: string, targetVersion: string): UpgradeEntry[] { + if (!valid(fromVersion)) throw new Error(`Invalid upgrade baseline version: ${fromVersion}.`); + if (!valid(targetVersion)) throw new Error(`Invalid installed Nimbus version: ${targetVersion}.`); + if (gt(fromVersion, targetVersion)) { + throw new Error(`Upgrade baseline ${fromVersion} is newer than installed Nimbus ${targetVersion}.`); + } + if (lt(fromVersion, UPGRADE_MANIFEST.oldestSupportedVersion)) { + throw new Error( + `Upgrade baseline ${fromVersion} predates the complete manifest. Start from Nimbus ${UPGRADE_MANIFEST.oldestSupportedVersion} or upgrade in supported stages.`, + ); + } + return UPGRADE_MANIFEST.entries + .filter((entry) => gt(entry.introducedIn, fromVersion) && lte(entry.introducedIn, targetVersion)) + .sort((a, b) => compare(a.introducedIn, b.introducedIn) || a.id.localeCompare(b.id)); +} + +export function resolveUpgradeBaseline(options: { + projectRoot: string; + fromVersion?: string; + targetVersion?: string; +}): UpgradeBaseline { + const runningVersion = runningNimbusVersion(); + let installedVersion: string | null = null; + try { + installedVersion = options.targetVersion === undefined + ? installedNimbusVersion(options.projectRoot) + : null; + } catch (error) { + return { + fromVersion: null, + targetVersion: runningVersion, + source: "missing", + error: errorMessage(error), + }; + } + const targetVersion = options.targetVersion ?? installedVersion ?? runningVersion; + if (!valid(targetVersion)) { + return { fromVersion: null, targetVersion, source: "missing", error: `Invalid installed Nimbus version: ${targetVersion}.` }; + } + if (installedVersion && !eq(installedVersion, runningVersion)) { + return { + fromVersion: null, + targetVersion, + source: "missing", + error: `The executing Nimbus CLI is ${runningVersion}, but the selected project has Nimbus ${installedVersion} installed. Run the project's installed nimbus-docs command.`, + }; + } + if (options.fromVersion !== undefined) { + const fromVersion = options.fromVersion.trim(); + if (!valid(fromVersion)) { + return { fromVersion: null, targetVersion, source: "argument", error: `--from must be an exact semantic version, received ${options.fromVersion}.` }; + } + if (gt(fromVersion, targetVersion)) { + return { fromVersion, targetVersion, source: "argument", error: `--from ${fromVersion} is newer than installed Nimbus ${targetVersion}.` }; + } + if (lt(fromVersion, UPGRADE_MANIFEST.oldestSupportedVersion)) { + return { + fromVersion, + targetVersion, + source: "argument", + error: `--from ${fromVersion} predates the complete upgrade manifest. The oldest supported baseline is ${UPGRADE_MANIFEST.oldestSupportedVersion}.`, + }; + } + const file = path.join(options.projectRoot, "nimbus.json"); + if (fs.existsSync(file)) { + try { + const persisted = (JSON.parse(fs.readFileSync(file, "utf8")) as { lastReviewedNimbusVersion?: unknown }) + .lastReviewedNimbusVersion; + if (typeof persisted === "string" && valid(persisted) && !eq(persisted, fromVersion)) { + return { + fromVersion, + targetVersion, + source: "argument", + error: `--from ${fromVersion} does not match the recorded Nimbus baseline ${persisted}.`, + }; + } + } catch (error) { + return { + fromVersion, + targetVersion, + source: "argument", + error: baselineReadError(error), + }; + } + } + return { fromVersion, targetVersion, source: "argument" }; + } + + const file = path.join(options.projectRoot, "nimbus.json"); + if (!fs.existsSync(file)) return { fromVersion: null, targetVersion, source: "missing" }; + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { + lastReviewedNimbusVersion?: unknown; + preview?: unknown; + }; + const value = parsed.lastReviewedNimbusVersion; + if (value === undefined || value === null || value === "") { + return { + fromVersion: null, + targetVersion, + source: parsed.preview && typeof parsed.preview === "object" && hasPreviewNimbusDependency(options.projectRoot) + ? "preview" + : "nimbus-json", + }; + } + if (typeof value !== "string" || !valid(value)) { + return { fromVersion: null, targetVersion, source: "nimbus-json", error: "nimbus.json lastReviewedNimbusVersion must be an exact semantic version or null." }; + } + if (gt(value, targetVersion)) { + return { fromVersion: value, targetVersion, source: "nimbus-json", error: `nimbus.json was reviewed with Nimbus ${value}, newer than installed Nimbus ${targetVersion}.` }; + } + if (lt(value, UPGRADE_MANIFEST.oldestSupportedVersion)) { + return { + fromVersion: value, + targetVersion, + source: "nimbus-json", + error: `nimbus.json lastReviewedNimbusVersion ${value} predates the complete upgrade manifest. The oldest supported baseline is ${UPGRADE_MANIFEST.oldestSupportedVersion}.`, + }; + } + return { fromVersion: value, targetVersion, source: "nimbus-json" }; + } catch (error) { + return { fromVersion: null, targetVersion, source: "nimbus-json", error: baselineReadError(error) }; + } +} + +function baselineReadError(error: unknown): string { + return `Could not read nimbus.json: ${errorMessage(error)}. Back up and repair the file. If its starter and registry provenance can be discarded, run \`nimbus-docs init --force\` from the affected project root to recreate it.`; +} + +function hasPreviewNimbusDependency(projectRoot: string): boolean { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + }; + const spec = manifest.dependencies?.["@cloudflare/nimbus-docs"] ?? + manifest.devDependencies?.["@cloudflare/nimbus-docs"]; + return typeof spec === "string" && /^https:\/\/pkg\.pr\.new\/@cloudflare\/nimbus-docs@/.test(spec); + } catch { + return false; + } +} + +export function installedNimbusVersion(projectRoot: string): string | null { + let current = path.resolve(projectRoot); + const filesystemRoot = path.parse(current).root; + while (true) { + const file = path.join(current, "node_modules", "@cloudflare", "nimbus-docs", "package.json"); + let present = false; + try { + fs.lstatSync(file); + present = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (present) { + try { + const version = (JSON.parse(fs.readFileSync(file, "utf8")) as { version?: unknown }).version; + if (typeof version === "string" && valid(version)) return version; + throw new Error(`Installed Nimbus package at ${file} has an invalid version.`); + } catch (error) { + throw new Error(`Could not read installed Nimbus package metadata at ${file}: ${errorMessage(error)}`); + } + } + if (current === filesystemRoot) return null; + current = path.dirname(current); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/nimbus-docs/src/check/finding.ts b/packages/nimbus-docs/src/check/finding.ts index 09035ee0..f11692e5 100644 --- a/packages/nimbus-docs/src/check/finding.ts +++ b/packages/nimbus-docs/src/check/finding.ts @@ -13,7 +13,7 @@ import { isBuildValidator, isRuleCode, type Diagnostic } from "../lint/diagnostic.js"; -export type CheckScope = "env" | "structure" | "authoring" | "types"; +export type CheckScope = "env" | "structure" | "migrations" | "authoring" | "types"; export type CheckSeverity = "error" | "warn"; @@ -45,6 +45,12 @@ export interface CheckFinding { message: string; fixable: boolean; fix?: CheckFix; + migration?: { + id: string; + introducedIn: string; + state: "available" | "blocked"; + command: { bin: string; args: string[]; cwd: string; display: string }; + }; } /** A sub-check that could not run. Not a finding; resolves by making something exist. */ @@ -112,7 +118,7 @@ export function deriveScopeStatus(r: ScopeReport): ScopeStatus { */ function isBuildBreaking(f: CheckFinding): boolean { if (f.severity !== "error") return false; - if (f.scope === "env" || f.scope === "structure") return true; + if (f.scope === "env" || f.scope === "structure" || f.scope === "migrations") return true; return isRuleCode(f.code) && isBuildValidator(f.code); } @@ -161,8 +167,9 @@ export function sortFindings(findings: CheckFinding[]): CheckFinding[] { const scopeRank: Record = { env: 0, structure: 1, - authoring: 2, - types: 3, + migrations: 2, + authoring: 3, + types: 4, }; return findings.sort( (a, b) => diff --git a/packages/nimbus-docs/src/check/format.ts b/packages/nimbus-docs/src/check/format.ts index d3165854..4e4a68d4 100644 --- a/packages/nimbus-docs/src/check/format.ts +++ b/packages/nimbus-docs/src/check/format.ts @@ -29,6 +29,7 @@ export interface PrettyOptions { const SCOPE_LABELS: Record = { env: "Environment", structure: "Structure", + migrations: "Migrations", authoring: "Authoring", types: "Types", }; @@ -67,6 +68,7 @@ export function formatCheckJson(result: CheckResult): string { message: f.message, fixable: f.fixable, ...(f.fix ? { fix: f.fix } : {}), + ...(f.migration ? { migration: f.migration } : {}), })), }, null, @@ -227,8 +229,8 @@ function passedHeadline( warnings > 0 && !opts.quiet ? paint(COLORS.dim, ` (${warnings} advisory warning${warnings === 1 ? "" : "s"})`) : ""; - const { env, structure, authoring, types } = result.requested; - const headline = env && structure && authoring && types + const { env, structure, authoring, types, migrations } = result.requested; + const headline = env && structure && authoring && types && migrations !== false ? ` ✓ Ready — buildability + correctness passed in ${secs}s` : ` ✓ ${scopeList(result).join(" + ")} passed — checked in ${secs}s`; return [paint(COLORS.green, headline) + advisory]; diff --git a/packages/nimbus-docs/src/check/migrations.ts b/packages/nimbus-docs/src/check/migrations.ts new file mode 100644 index 00000000..8e4356f2 --- /dev/null +++ b/packages/nimbus-docs/src/check/migrations.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; + +import { discoverMigrations } from "../_internal/migrations.js"; +import { resolveUpgradeBaseline, selectUpgradeEntries } from "../_internal/upgrades.js"; +import type { ScopeReport } from "./finding.js"; + +export function checkMigrations(cwd: string, srcDirOverride?: string): ScopeReport { + const baseline = resolveUpgradeBaseline({ projectRoot: cwd }); + const entries = baseline.fromVersion && !baseline.error + ? selectUpgradeEntries(baseline.fromVersion, baseline.targetVersion) + : []; + const discovery = discoverMigrations({ + projectRoot: cwd, + srcDirOverride, + allowUnresolvedLayout: baseline.fromVersion === baseline.targetVersion && !baseline.error, + }); + const baselineBlocked = Boolean(baseline.error || (!baseline.fromVersion && baseline.source !== "preview")); + const entry = process.argv[1] ? fs.realpathSync(process.argv[1]) : "nimbus-docs"; + const migrateArgs = [entry, "migrate", ...(srcDirOverride ? ["--src-dir", srcDirOverride] : [])]; + const command = { + bin: process.execPath, + args: migrateArgs, + cwd: ".", + display: [process.execPath, ...migrateArgs].map(shell).join(" "), + }; + return { + scope: "migrations", + findings: [ + ...discovery.plans.flatMap((plan) => { + const locations = plan.locations.length > 0 ? plan.locations : [undefined]; + return locations.map((location) => ({ + scope: "migrations" as const, + code: "nimbus/migration", + severity: "error" as const, + ...(location + ? { + file: location.file, + line: location.line, + column: location.column, + } + : {}), + message: `${plan.summary} Run \`${command.display}\` to review migration \`${plan.id}\`.`, + fixable: false, + migration: { + id: plan.id, + introducedIn: plan.introducedIn, + state: plan.blockers.length > 0 ? ("blocked" as const) : ("available" as const), + command, + }, + })); + }), + ...(baseline.fromVersion && !baseline.error + ? entries.map((entry) => ({ + scope: "migrations" as const, + code: "nimbus/upgrade-review", + severity: "error" as const, + message: `${entry.summary} Review upgrade \`${entry.id}\` with \`${command.display}\`.`, + fixable: false, + migration: { + id: entry.id, + introducedIn: entry.introducedIn, + state: "blocked" as const, + command, + }, + })) + : []), + ...(baselineBlocked + ? [{ + scope: "migrations" as const, + code: "nimbus/upgrade-baseline", + severity: "error" as const, + message: baseline.error ?? `nimbus.json has no reviewed Nimbus version. Run \`${command.display} --from \`.`, + fixable: false, + }] + : []), + ], + notes: [ + ...(discovery.coverage + ? [{ code: `nimbus/${discovery.coverage.code}`, reason: discovery.coverage.message, requiresInput: true }] + : []), + ...(!baseline.fromVersion && !baselineBlocked + ? [{ + code: "nimbus/upgrade-baseline-missing", + reason: baseline.source === "preview" + ? "Preview scaffolds do not establish a stable Nimbus release baseline." + : `Nimbus cannot determine the previously reviewed version. Run \`${command.display} --from \`.`, + requiresInput: true, + }] + : []), + ], + evaluated: true, + }; +} + +function shell(value: string): string { + return /^[A-Za-z0-9@._/:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/packages/nimbus-docs/src/check/run.ts b/packages/nimbus-docs/src/check/run.ts index 4876eaa6..51ec7440 100644 --- a/packages/nimbus-docs/src/check/run.ts +++ b/packages/nimbus-docs/src/check/run.ts @@ -15,6 +15,7 @@ import { } from "../_internal/parse-nimbus-config.js"; import { checkAuthoring } from "./authoring.js"; import { checkEnv } from "./env.js"; +import { checkMigrations } from "./migrations.js"; import { deriveReadiness, deriveScopeStatus, @@ -37,6 +38,7 @@ export interface CheckScopes { structure: boolean; authoring: boolean; types: boolean; + migrations: boolean; } export const ALL_SCOPES: CheckScopes = { @@ -44,6 +46,7 @@ export const ALL_SCOPES: CheckScopes = { structure: true, authoring: true, types: true, + migrations: true, }; /** A runner's `ScopeReport` plus its derived verdict, ready to render. */ @@ -74,6 +77,7 @@ export interface CheckResult { export async function runChecks( cwd: string, scopes: CheckScopes = ALL_SCOPES, + options: { srcDir?: string } = {}, ): Promise { const started = performance.now(); @@ -82,6 +86,7 @@ export async function runChecks( const reports: ScopeReport[] = []; if (scopes.env) reports.push(checkEnv(cwd, parsed)); if (scopes.structure) reports.push(await checkStructure(cwd, parsed)); + if (scopes.migrations) reports.push(checkMigrations(cwd, options.srcDir)); if (scopes.authoring) reports.push(checkAuthoring(cwd)); if (scopes.types) reports.push(checkTypes(cwd)); diff --git a/packages/nimbus-docs/src/cli/check.ts b/packages/nimbus-docs/src/cli/check.ts index a6326f4d..f3123bdc 100644 --- a/packages/nimbus-docs/src/cli/check.ts +++ b/packages/nimbus-docs/src/cli/check.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { spawnSync, type StdioOptions } from "node:child_process"; +import fs from "node:fs"; import * as p from "@clack/prompts"; @@ -30,12 +31,14 @@ export interface CheckCliFlags { structure?: boolean; lint?: boolean; types?: boolean; + migrations?: boolean; fix?: boolean; json?: boolean; format?: string; quiet?: boolean; color?: boolean; yes?: boolean; + srcDir?: string; } export async function checkCommand(flags: CheckCliFlags): Promise { @@ -50,7 +53,7 @@ export async function checkCommand(flags: CheckCliFlags): Promise { const scopes = resolveScopes(flags); const wantJson = flags.json === true || flags.format === "json"; - let result = await runChecks(cwd, scopes); + let result = await runChecks(cwd, scopes, { srcDir: flags.srcDir }); let interrupted = false; if (flags.fix) { @@ -76,7 +79,7 @@ export async function checkCommand(flags: CheckCliFlags): Promise { } finally { process.off("SIGINT", onSigint); } - result = await runChecks(cwd, scopes); + result = await runChecks(cwd, scopes, { srcDir: flags.srcDir }); } if (wantJson) { @@ -96,13 +99,14 @@ export async function checkCommand(flags: CheckCliFlags): Promise { } export function resolveScopes(flags: CheckCliFlags): CheckScopes { - const any = flags.env || flags.structure || flags.lint || flags.types; + const any = flags.env || flags.structure || flags.lint || flags.types || flags.migrations; if (!any) return ALL_SCOPES; return { env: flags.env === true, structure: flags.structure === true, authoring: flags.lint === true, types: flags.types === true, + migrations: flags.migrations === true, }; } @@ -175,9 +179,20 @@ async function fixSetConfig( }, }); if (p.isCancel(value)) return; + let currentSource: string; + try { + currentSource = fs.readFileSync(result.location.file, "utf8"); + } catch { + p.log.warn(`Skipped updating ${path.relative(cwd, result.location.file)} because it changed or became unreadable while Nimbus was waiting for input. Rerun nimbus-docs check.`); + return; + } + if (currentSource !== result.location.source) { + p.log.warn(`Skipped updating ${path.relative(cwd, result.location.file)} because it changed while Nimbus was waiting for input. Rerun nimbus-docs check.`); + return; + } const next = rewriteConfigField(result.location, "site", value); - writeFileAtomic(result.location.file, next); + writeFileAtomic(result.location.file, next, { expectedContent: result.location.source }); applied.push(`set site to ${value} in ${path.relative(cwd, result.location.file)}`); } diff --git a/packages/nimbus-docs/src/cli/fs-atomic.ts b/packages/nimbus-docs/src/cli/fs-atomic.ts index d87a48d5..f134a716 100644 --- a/packages/nimbus-docs/src/cli/fs-atomic.ts +++ b/packages/nimbus-docs/src/cli/fs-atomic.ts @@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto"; export function writeFileAtomic( file: string, content: string, - options: { overwrite?: boolean } = {}, + options: { overwrite?: boolean; expectedContent?: string } = {}, ): void { const tmp = `${file}.nimbus-tmp-${process.pid}-${randomUUID()}`; try { @@ -24,6 +24,17 @@ export function writeFileAtomic( } finally { fs.closeSync(fd); } + if (options.expectedContent !== undefined) { + let current: string; + try { + current = fs.readFileSync(file, "utf8"); + } catch { + throw new Error(`Refusing to replace ${file} because it changed or became unreadable during the write.`); + } + if (current !== options.expectedContent) { + throw new Error(`Refusing to replace ${file} because it changed during the write.`); + } + } if (options.overwrite === false) { fs.linkSync(tmp, file); try { diff --git a/packages/nimbus-docs/src/cli/index.ts b/packages/nimbus-docs/src/cli/index.ts index 33b79047..797a4386 100644 --- a/packages/nimbus-docs/src/cli/index.ts +++ b/packages/nimbus-docs/src/cli/index.ts @@ -38,6 +38,7 @@ import { loadDotenv } from "./dotenv.js"; import { installFeature, shouldUseAgentHandoff } from "./feature.js"; import { initCommand } from "./init.js"; import { lintCommand } from "./lint.js"; +import { migrateCommand } from "./migrate.js"; import { readNimbusJson, recordInstalled, @@ -95,11 +96,14 @@ interface CliArgs { overwrite: boolean; all: boolean; apply: boolean; + diff: boolean; env: boolean; structure: boolean; lint: boolean; types: boolean; json: boolean; + migrations: boolean; + "dry-run": boolean; type?: string; format?: string; rule?: string; @@ -107,6 +111,9 @@ interface CliArgs { to?: string; adapter?: string; "template-dir"?: string; + cwd?: string; + "src-dir"?: string; + from?: string; color?: boolean; } @@ -119,27 +126,33 @@ const HELP = ` Opt into server output: flip \`output\` to "server" + wire the adapter add adapter- Alias for server-output adapter installs - check Build-free preflight: env + structure + authoring + types (--fix, --json) + check Build-free preflight: env + structure + authoring + types + migrations + migrate Plan and apply known Nimbus package API migrations init Create the committed nimbus.json record (adopt an existing project) - outdated Show what's behind upstream (starter files + registry components) + outdated Show outdated starter, registry, and package API work diff [file] Show upstream/your changes to starter files (read-only) lint Lint .mdx content for authoring-quality issues Flags: - --yes, -y Assume yes for prompts; keep existing files on conflict + --yes, -y Assume yes for prompts (command-specific writes still apply) --overwrite \`add\`: replace existing files with registry versions (upgrade) --apply \`diff \`: write the upstream change (clean files only) + --dry-run \`migrate\`: plan without prompting or writing + --diff \`migrate\`: print proposed edits without prompting or writing --all \`outdated\`/\`diff\`: include content files (hidden by default) --to \`outdated\`/\`diff\`: compare against a specific tag (default latest) --template-dir \`outdated\`/\`diff\`: compare against a local checkout (offline) - --print \`add\`: print a feature or Cloudflare adapter recipe + --print \`add\`/\`migrate\`: print an agent task without writing --force \`init\`: rebuild an existing nimbus.json --root \`init\`: src dir to scan (monorepo; default src) - --env, --structure, --lint, --types + --env, --structure, --lint, --types, --migrations \`check\`: run only the named categories (default: all) --type \`list\`: filter by type --format \`lint\`/\`check\`: machine-readable output - --json \`check\`: machine-readable output (alias for --format=json) + --json \`check\`/\`migrate\`/\`outdated\`: machine-readable output + --cwd \`migrate\`: target a nested Astro project + --src-dir Migration scan source dir when Astro config is computed + --from \`migrate\`: previous reviewed Nimbus version when nimbus.json has no baseline --rule \`lint\`: run a single rule --fix \`lint\`/\`check\`: apply auto-fixes in place --quiet \`lint\`/\`check\`: errors only, suppress warnings @@ -149,9 +162,11 @@ const HELP = ` Examples (run with your package manager — see Usage above): nimbus-docs add dialog # component: resolve + install nimbus-docs add card --overwrite # re-install over your copy (review with git) - nimbus-docs check # build-free preflight (env + structure + authoring + types) + nimbus-docs check # build-free preflight (env + structure + authoring + types + migrations) nimbus-docs check --json # agent-readable findings + fixes nimbus-docs check --fix # apply safe fixes, prompt for the rest + nimbus-docs migrate # review package API migrations + nimbus-docs migrate --yes --json # apply safe migrations for an agent nimbus-docs outdated # what's behind upstream (starter + registry) nimbus-docs init # adopt an existing repo — writes nimbus.json nimbus-docs add 404-page --print | claude # explicit pipe to claude @@ -172,8 +187,8 @@ const HELP = ` async function main(): Promise { const args = mri(process.argv.slice(2), { - boolean: ["yes", "print", "help", "version", "quiet", "color", "fix", "force", "overwrite", "all", "apply", "env", "structure", "lint", "types", "json"], - string: ["type", "format", "rule", "root", "to", "adapter", "template-dir"], + boolean: ["yes", "print", "help", "version", "quiet", "color", "fix", "force", "overwrite", "all", "apply", "diff", "dry-run", "env", "structure", "lint", "types", "migrations", "json"], + string: ["type", "format", "rule", "root", "to", "adapter", "template-dir", "cwd", "src-dir", "from"], default: { color: undefined }, alias: { y: "yes", h: "help", v: "version" }, }) as unknown as CliArgs; @@ -199,12 +214,14 @@ async function main(): Promise { structure: args.structure, lint: args.lint, types: args.types, + migrations: args.migrations, fix: args.fix, json: args.json, format: args.format, quiet: args.quiet, color: args.color, yes: args.yes, + srcDir: args["src-dir"], }); return; } @@ -228,7 +245,22 @@ async function main(): Promise { } if (command === "outdated") { - await outdatedCommand({ all: args.all, to: args.to, templateDir: args["template-dir"] }); + await outdatedCommand({ all: args.all, to: args.to, templateDir: args["template-dir"], json: args.json, srcDir: args["src-dir"] }); + return; + } + + if (command === "migrate") { + await migrateCommand({ + yes: args.yes, + json: args.json, + print: args.print, + dryRun: args["dry-run"], + diff: args.diff, + cwd: args.cwd, + srcDir: args["src-dir"], + fromVersion: args.from, + color: args.color, + }); return; } diff --git a/packages/nimbus-docs/src/cli/init.ts b/packages/nimbus-docs/src/cli/init.ts index 19bb3f38..1b19394d 100644 --- a/packages/nimbus-docs/src/cli/init.ts +++ b/packages/nimbus-docs/src/cli/init.ts @@ -207,6 +207,7 @@ export async function initCommand(flags: InitFlags): Promise { // create-nimbus-docs version + templates tag aren't recoverable from the // repo alone; the upgrade commands read `reconstructed` to know starter provenance is partial. version: null, + lastReviewedNimbusVersion: null, templatesTag: null, variant: null, registry: registrySource(), @@ -240,6 +241,7 @@ async function reportReadiness(cwd: string): Promise { const result = await runChecks(cwd, { env: true, structure: false, + migrations: false, authoring: false, types: false, }); diff --git a/packages/nimbus-docs/src/cli/migrate.ts b/packages/nimbus-docs/src/cli/migrate.ts new file mode 100644 index 00000000..5bbfef72 --- /dev/null +++ b/packages/nimbus-docs/src/cli/migrate.ts @@ -0,0 +1,649 @@ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline/promises"; + +import { compare } from "semver"; + +import { + discoverMigrations, + type MigrationBlocker, + type MigrationChange, + type MigrationPlan, +} from "../_internal/migrations.js"; +import { + resolveUpgradeBaseline, + runningNimbusVersion, + selectUpgradeEntries, + type UpgradeBaseline, + type UpgradeEntry, +} from "../_internal/upgrades.js"; +import { writeFileAtomic } from "./fs-atomic.js"; +import { NIMBUS_JSON, readNimbusJson } from "./nimbus-json.js"; + +export interface MigrateOptions { + cwd?: string; + srcDir?: string; + yes?: boolean; + dryRun?: boolean; + diff?: boolean; + json?: boolean; + print?: boolean; + fromVersion?: string; + targetVersion?: string; + color?: boolean; +} + +type MigrationState = "available" | "blocked" | "applied" | "failed"; +type ChangeOutcome = "planned" | "applied" | "not_written"; + +interface ResultError { + code: string; + message: string; + file?: string; +} + +export interface MigrationResult { + id: string; + state: MigrationState; + locations: MigrationPlan["locations"]; + changes: Array<{ file: string; diff: string; outcome: ChangeOutcome }>; + blockers: MigrationBlocker[]; + instructions: string[]; + errors: ResultError[]; +} + +interface MigrateReport { + schemaVersion: 1; + status: "passed" | "changes_available" | "blocked" | "failed"; + baseline: UpgradeBaseline & { recorded: boolean }; + migrations: MigrationResult[]; + reviews: UpgradeEntry[]; + errors: ResultError[]; +} + +type CompletionOptions = Pick; + +export async function migrateCommand(input: MigrateOptions): Promise { + const completionOptions = { cwd: input.cwd, srcDir: input.srcDir }; + const options = { + srcDir: input.srcDir, + yes: input.yes ?? false, + dryRun: input.dryRun ?? false, + diff: input.diff ?? false, + json: input.json ?? false, + print: input.print ?? false, + fromVersion: input.fromVersion, + targetVersion: input.targetVersion, + }; + const invalid = invalidFlags(options); + if (invalid) { + const report = makeReport(emptyBaseline(options.targetVersion), [], [], [{ code: "invalid-arguments", message: invalid }]); + if (options.json) process.stdout.write(`${JSON.stringify(report)}\n`); + else console.error(invalid); + process.exitCode = 2; + return; + } + + const selectedRoot = resolveProjectRoot(process.cwd(), input.cwd); + if (!selectedRoot.ok) { + finish(makeReport(emptyBaseline(options.targetVersion), [], [], [{ code: "invalid-project-root", message: selectedRoot.message }]), options.json, false, completionOptions); + process.exitCode = 1; + return; + } + const projectRoot = selectedRoot.root; + const baseline = resolveUpgradeBaseline({ + projectRoot, + fromVersion: options.fromVersion, + targetVersion: options.targetVersion, + }); + if (baseline.error) { + finish(makeReport(baseline, [], [], [{ code: "invalid-upgrade-baseline", message: baseline.error }], false, false, false), options.json, false, completionOptions); + process.exitCode = 1; + return; + } + const baselineFile = path.join(projectRoot, NIMBUS_JSON); + const baselinePreimage = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, "utf8") : null; + const recordedVersion = baselinePreimage === null + ? null + : (JSON.parse(baselinePreimage) as { lastReviewedNimbusVersion?: unknown }).lastReviewedNimbusVersion; + const baselineNeedsRecording = baseline.source !== "preview" && recordedVersion !== baseline.targetVersion; + const entries = baseline.fromVersion + ? selectUpgradeEntries(baseline.fromVersion, baseline.targetVersion) + : []; + const reviews = entries; + let discovery: ReturnType; + try { + discovery = discoverMigrations({ + projectRoot, + srcDirOverride: options.srcDir, + allowUnresolvedLayout: !baselineNeedsRecording && entries.every((entry) => !entry.migrationId), + }); + } catch (error) { + finish(makeReport(baseline, [], reviews, [{ code: "discovery-failed", message: errorMessage(error) }]), options.json, false, completionOptions); + process.exitCode = 1; + return; + } + + if (options.print) { + process.stdout.write(renderTask(discovery.plans, reviews, baseline, baselineNeedsRecording, completionOptions)); + return; + } + + const blocked = discovery.plans.filter((plan) => plan.blockers.length > 0); + const safe = discovery.plans.filter((plan) => plan.blockers.length === 0); + const canRecordBaseline = Boolean( + baseline.fromVersion && discovery.plans.length === 0 && baselineNeedsRecording, + ); + const readOnly = options.dryRun || options.diff || (options.json && !options.yes) || + (!options.yes && (!process.stdin.isTTY || !process.stdout.isTTY)); + let consent = options.yes; + + if (!readOnly && !consent && process.stdin.isTTY && process.stdout.isTTY) { + printHumanPlan(discovery.plans, reviews, baseline, completionOptions); + if (safe.length > 0) { + consent = await confirm(`Apply ${safe.length} safe migration${safe.length === 1 ? "" : "s"}?`); + } else if (blocked.length === 0 && canRecordBaseline) { + consent = await confirm("Record the installed Nimbus version as reviewed?"); + } + } + + if (readOnly && !options.json && !options.diff) { + printHumanPlan(discovery.plans, reviews, baseline, completionOptions); + process.exitCode = discovery.plans.length > 0 || reviews.length > 0 || baselineNeedsRecording ? 1 : 0; + return; + } + + const results: MigrationResult[] = blocked.map(blockedResult); + for (const plan of safe) { + if (readOnly || !consent) results.push(availableResult(plan)); + else results.push(applyMigrationPlan(projectRoot, options.srcDir, plan)); + } + results.sort((a, b) => a.id.localeCompare(b.id)); + + if (options.diff) { + for (const plan of safe) printPlanDiff(plan); + for (const plan of blocked) printBlockedPlan(plan); + printUpgradeReviews(reviews, baseline); + printCompletionCommand(reviews, baseline, completionOptions); + process.exitCode = discovery.plans.length > 0 || reviews.length > 0 || baselineNeedsRecording || (!baseline.fromVersion && baseline.source !== "preview") ? 1 : 0; + return; + } + const report = makeReport(baseline, results, reviews, [], false, baselineNeedsRecording); + if (!readOnly && consent && canRecordBaseline) { + const latest = discoverMigrations({ projectRoot, srcDirOverride: options.srcDir }); + if (latest.plans.length > 0) { + const latestResults = latest.plans.map((plan) => + plan.blockers.length > 0 ? blockedResult(plan) : availableResult(plan) + ); + const changed = makeReport(baseline, latestResults, reviews, [], false, true, false); + finish(changed, options.json, false, completionOptions); + process.exitCode = 1; + return; + } + const errors = recordUpgradeBaseline(projectRoot, baseline.targetVersion, baselinePreimage); + const recorded = errors.length === 0; + const completed = makeReport(baseline, [], reviews, errors, recorded, !recorded, recorded); + finish(completed, options.json, recorded, completionOptions); + if (completed.status !== "passed") process.exitCode = 1; + return; + } + finish(report, options.json, false, completionOptions); + if (report.status !== "passed") process.exitCode = 1; +} + +export function applyMigrationPlan( + projectRoot: string, + srcDirOverride: string | undefined, + plan: MigrationPlan, +): MigrationResult { + const planned = plannedChanges(plan, "not_written"); + const preflight = preflightPlan(projectRoot, plan); + if (preflight.length > 0) { + return result(plan, "failed", planned, [], preflight); + } + + const changes: MigrationResult["changes"] = []; + const errors: ResultError[] = []; + for (const change of plan.changes) { + try { + const finalCheck = preflightPlan(projectRoot, { ...plan, changes: [change] }); + if (finalCheck.length > 0) { + errors.push(...finalCheck); + changes.push(resultChange(change, "not_written")); + break; + } + writeFileAtomic(change.absoluteFile, change.after, { expectedContent: change.before }); + changes.push(resultChange(change, "applied")); + } catch (error) { + errors.push({ code: "write-failed", file: change.file, message: errorMessage(error) }); + changes.push(resultChange(change, "not_written")); + break; + } + } + const attempted = new Set(changes.map((change) => change.file)); + for (const change of plan.changes) { + if (!attempted.has(change.file)) changes.push(resultChange(change, "not_written")); + } + changes.sort((a, b) => a.file.localeCompare(b.file)); + + if (changes.some((change) => change.outcome === "applied")) { + try { + const rediscovered = discoverMigrations({ projectRoot, srcDirOverride }); + if (rediscovered.coverage && errors.length === 0) { + errors.push({ code: rediscovered.coverage.code, message: rediscovered.coverage.message }); + } else if (errors.length === 0 && rediscovered.plans.some((candidate) => candidate.id === plan.id)) { + errors.push({ + code: "postcondition-failed", + message: "The migration still applies after its writes. Inspect the changed files before continuing.", + }); + } + } catch (error) { + if (errors.length === 0) errors.push({ code: "postcondition-failed", message: errorMessage(error) }); + } + } + return result(plan, errors.length === 0 ? "applied" : "failed", changes, [], errors); +} + +function preflightPlan(projectRoot: string, plan: MigrationPlan): ResultError[] { + const errors: ResultError[] = []; + let realRoot: string; + try { + realRoot = fs.realpathSync(projectRoot); + } catch (error) { + return [{ code: "project-unreadable", message: errorMessage(error) }]; + } + + for (const change of plan.changes) { + try { + const relative = path.relative(projectRoot, change.absoluteFile); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + errors.push({ code: "path-escape", file: change.file, message: "Planned output is outside the selected project." }); + continue; + } + if (fs.lstatSync(change.absoluteFile).isSymbolicLink()) { + errors.push({ code: "symlink-target", file: change.file, message: "Refusing to write through a symbolic link." }); + continue; + } + const realFile = fs.realpathSync(change.absoluteFile); + const realRelative = path.relative(realRoot, realFile); + if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) { + errors.push({ code: "path-escape", file: change.file, message: "Planned output resolves outside the selected project." }); + continue; + } + if (fs.readFileSync(change.absoluteFile, "utf8") !== change.before) { + errors.push({ code: "preimage-mismatch", file: change.file, message: "The file changed after migration discovery." }); + } + } catch (error) { + errors.push({ code: "preflight-failed", file: change.file, message: errorMessage(error) }); + } + } + return errors.sort(errorOrder); +} + +function availableResult(plan: MigrationPlan): MigrationResult { + return result(plan, "available", plannedChanges(plan, "planned"), [], []); +} + +function blockedResult(plan: MigrationPlan): MigrationResult { + return result(plan, "blocked", [], plan.blockers, []); +} + +function result( + plan: MigrationPlan, + state: MigrationState, + changes: MigrationResult["changes"], + blockers: MigrationBlocker[], + errors: ResultError[], +): MigrationResult { + return { + id: plan.id, + state, + locations: [...plan.locations].sort(locationOrder), + changes: [...changes].sort((a, b) => a.file.localeCompare(b.file)), + blockers: [...blockers].sort(errorOrder), + instructions: state === "blocked" || state === "failed" ? plan.instructions : [], + errors: [...errors].sort(errorOrder), + }; +} + +function plannedChanges(plan: MigrationPlan, outcome: ChangeOutcome): MigrationResult["changes"] { + return plan.changes.map((change) => resultChange(change, outcome)); +} + +function resultChange(change: MigrationChange, outcome: ChangeOutcome): MigrationResult["changes"][number] { + return { file: change.file, diff: unifiedDiff(change), outcome }; +} + +function makeReport( + baseline: UpgradeBaseline, + migrations: MigrationResult[], + reviews: UpgradeEntry[], + errors: ResultError[], + reviewsCompleted = false, + baselinePending = false, + baselineRecorded = !baselinePending && Boolean(baseline.fromVersion), +): MigrateReport { + let status: MigrateReport["status"] = "passed"; + if (errors.length > 0 || migrations.some((migration) => migration.state === "failed")) status = "failed"; + else if ((!baseline.fromVersion && baseline.source !== "preview") || baselinePending || (!reviewsCompleted && reviews.length > 0) || migrations.some((migration) => migration.state === "blocked")) status = "blocked"; + else if (migrations.some((migration) => migration.state === "available")) status = "changes_available"; + return { + schemaVersion: 1, + status, + baseline: { ...baseline, recorded: baselineRecorded }, + migrations: [...migrations].sort((a, b) => a.id.localeCompare(b.id)), + reviews: [...reviews].sort(upgradeOrder), + errors: [...errors].sort(errorOrder), + }; +} + +function finish( + report: MigrateReport, + json: boolean, + reviewsCompleted = false, + completionOptions: CompletionOptions = {}, +): void { + if (json) { + process.stdout.write(`${JSON.stringify(report)}\n`); + return; + } + for (const error of report.errors) console.error(`Migration discovery failed: ${error.message}`); + if (!report.baseline.fromVersion && report.baseline.source !== "preview" && report.errors.length === 0) { + console.error(`Upgrade baseline unknown. Rerun with --from , complete every review, then rerun with consent.`); + } + if (report.migrations.length === 0 && report.reviews.length === 0 && report.errors.length === 0 && report.baseline.fromVersion) { + console.log("No Nimbus migrations detected."); + } + for (const migration of report.migrations) { + console.log(`${migration.id}: ${migration.state}`); + for (const change of migration.changes) console.log(` ${change.outcome}: ${change.file}`); + for (const blocker of migration.blockers) console.error(` ${blocker.file ? `${blocker.file}: ` : ""}${blocker.message}`); + for (const error of migration.errors) console.error(` ${error.file ? `${error.file}: ` : ""}${error.message}`); + if (migration.instructions.length > 0) { + console.log(" Next steps:"); + for (const instruction of migration.instructions) console.log(` - ${instruction}`); + } + } + printUpgradeReviews(report.reviews, report.baseline); + if (!reviewsCompleted) printCompletionCommand(report.reviews, report.baseline, completionOptions); + if (reviewsCompleted) { + console.log(`Recorded Nimbus ${report.baseline.targetVersion} as the reviewed upgrade baseline in ${NIMBUS_JSON}.`); + } +} + +function printHumanPlan( + plans: MigrationPlan[], + reviews: UpgradeEntry[], + baseline: UpgradeBaseline, + completionOptions: CompletionOptions, +): void { + if (!baseline.fromVersion && baseline.source !== "preview") { + console.error("Upgrade baseline unknown. Pass --from to include every crossed breaking change."); + } + for (const plan of plans) { + console.log(`${plan.id}: ${plan.blockers.length > 0 ? "blocked" : `${plan.changes.length} planned file${plan.changes.length === 1 ? "" : "s"}`}`); + if (plan.blockers.length === 0) printPlanDiff(plan); + else for (const blocker of plan.blockers) console.error(` ${blocker.message}`); + } + printUpgradeReviews(reviews, baseline); + printCompletionCommand(reviews, baseline, completionOptions); +} + +function printPlanDiff(plan: MigrationPlan): void { + for (const change of plan.changes) console.log(unifiedDiff(change)); +} + +function printBlockedPlan(plan: MigrationPlan): void { + console.error(`${plan.id}: blocked`); + for (const location of plan.locations) { + console.error(` ${location.file}:${location.line}:${location.column}`); + } + for (const blocker of plan.blockers) console.error(` ${blocker.message}`); + console.error(" Next steps:"); + for (const instruction of plan.instructions) console.error(` - ${instruction}`); +} + +function unifiedDiff(change: MigrationChange): string { + return `--- a/${change.file}\n+++ b/${change.file}\n${diffHunk(change.before, change.after)}`; +} + +function diffHunk(before: string, after: string): string { + if (!/\r?\n$/.test(before) || !/\r?\n$/.test(after)) return fullFileDiffHunk(before, after); + const beforeLines = diffLines(before); + const afterLines = diffLines(after); + let start = 0; + while (start < beforeLines.length && start < afterLines.length && beforeLines[start] === afterLines[start]) start++; + let suffix = 0; + while ( + beforeLines.length - suffix - 1 >= start && + afterLines.length - suffix - 1 >= start && + beforeLines[beforeLines.length - suffix - 1] === afterLines[afterLines.length - suffix - 1] + ) { + suffix++; + } + const beforeChangedEnd = beforeLines.length - suffix; + const afterChangedEnd = afterLines.length - suffix; + const beforeStart = Math.max(0, start - 3); + const afterStart = Math.max(0, start - 3); + const beforeEnd = Math.min(beforeLines.length, beforeChangedEnd + 3); + const afterEnd = Math.min(afterLines.length, afterChangedEnd + 3); + const lines = [ + `@@ -${diffRange(beforeStart, beforeEnd - beforeStart)} +${diffRange(afterStart, afterEnd - afterStart)} @@`, + ...beforeLines.slice(beforeStart, start).map((line) => ` ${line}`), + ...beforeLines.slice(start, beforeChangedEnd).map((line) => `-${line}`), + ...afterLines.slice(start, afterChangedEnd).map((line) => `+${line}`), + ...beforeLines.slice(beforeChangedEnd, beforeEnd).map((line) => ` ${line}`), + ]; + return lines.join("\n"); +} + +function fullFileDiffHunk(before: string, after: string): string { + const beforeLines = diffLines(before); + const afterLines = diffLines(after); + const lines = [ + `@@ -${diffRange(0, beforeLines.length)} +${diffRange(0, afterLines.length)} @@`, + ...beforeLines.map((line) => `-${line}`), + ]; + if (before.length > 0 && !/\r?\n$/.test(before)) lines.push("\\ No newline at end of file"); + lines.push(...afterLines.map((line) => `+${line}`)); + if (after.length > 0 && !/\r?\n$/.test(after)) lines.push("\\ No newline at end of file"); + return lines.join("\n"); +} + +function diffLines(source: string): string[] { + const lines = source.split(/\r?\n/); + if (/\r?\n$/.test(source)) lines.pop(); + return lines; +} + +function diffRange(start: number, count: number): string { + return count === 0 ? `${start},0` : `${start + 1},${count}`; +} + +function resolveProjectRoot( + invocationRoot: string, + requested?: string, +): { ok: true; root: string } | { ok: false; message: string } { + const root = path.resolve(invocationRoot, requested ?? "."); + const relative = path.relative(invocationRoot, root); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return { ok: false, message: "--cwd must stay inside the invocation root." }; + } + try { + const realInvocation = fs.realpathSync(invocationRoot); + const realRoot = fs.realpathSync(root); + if (!fs.statSync(realRoot).isDirectory()) return { ok: false, message: "--cwd must select a directory." }; + const realRelative = path.relative(realInvocation, realRoot); + if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) { + return { ok: false, message: "--cwd resolves outside the invocation root." }; + } + } catch (error) { + return { ok: false, message: `Could not resolve --cwd: ${errorMessage(error)}` }; + } + return { ok: true, root }; +} + +function renderTask( + plans: MigrationPlan[], + reviews: UpgradeEntry[], + baseline: UpgradeBaseline, + baselineNeedsRecording: boolean, + completionOptions: CompletionOptions, +): string { + if (plans.length === 0 && reviews.length === 0 && !baselineNeedsRecording) { + return "# Nimbus migration task\n\nNo known Nimbus migrations or upgrade reviews are pending.\n"; + } + const lines = ["# Nimbus migration task", "", "Review and complete every migration below. Do not overwrite customized behavior.", ""]; + if (!baseline.fromVersion && baseline.source !== "preview") { + lines.push( + "## Upgrade baseline required", + "", + `Nimbus ${baseline.targetVersion} cannot determine the previously reviewed version. Rerun with --from .`, + "", + ); + } else { + lines.push(`Upgrade range: ${baseline.fromVersion} to ${baseline.targetVersion}`, ""); + } + for (const plan of plans) { + lines.push(`## ${plan.id}`, "", plan.summary, "", "Locations:"); + for (const location of plan.locations) lines.push(`- ${location.file}:${location.line}:${location.column}`); + if (plan.blockers.length > 0) { + lines.push("", "Why Nimbus did not edit this migration:"); + for (const blocker of plan.blockers) lines.push(`- ${blocker.file ? `${blocker.file}: ` : ""}${blocker.message}`); + } + lines.push("", "Required work:"); + for (const instruction of plan.instructions) lines.push(`- ${instruction}`); + lines.push(""); + } + for (const review of reviews) { + lines.push( + `## ${review.id}`, + "", + `${review.summary} (${review.introducedIn}, ${review.mode})`, + "", + `Affected: ${review.affected}`, + "", + "Required review:", + ); + for (const instruction of review.instructions) lines.push(`- ${instruction}`); + lines.push("", "Verification:"); + for (const verification of review.verify) lines.push(`- ${verification}`); + lines.push(""); + } + if (baseline.fromVersion && baselineNeedsRecording) { + lines.push( + "After completing every required review, rerun with consent before project verification:", + completionCommand(reviews, baseline, completionOptions), + "", + ); + } + return `${lines.join("\n")}\n`; +} + +async function confirm(message: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(`${message} [y/N] `); + return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"; + } finally { + rl.close(); + } +} + +function invalidFlags(options: { + yes: boolean; + dryRun: boolean; + diff: boolean; + json: boolean; + print: boolean; +}): string | null { + if (options.yes && (options.dryRun || options.diff)) return "--yes cannot be combined with --dry-run or --diff."; + if (options.diff && options.json) return "--diff cannot be combined with --json."; + if (options.print && (options.yes || options.json || options.diff || options.dryRun)) { + return "--print cannot be combined with --yes, --json, --diff, or --dry-run."; + } + return null; +} + +function emptyBaseline(targetVersion?: string): UpgradeBaseline { + return { + fromVersion: null, + targetVersion: targetVersion ?? runningNimbusVersion(), + source: "missing", + }; +} + +function recordUpgradeBaseline(projectRoot: string, targetVersion: string, expectedPreimage: string | null): ResultError[] { + const file = path.join(projectRoot, NIMBUS_JSON); + try { + const exists = fs.existsSync(file); + if (exists && fs.lstatSync(file).isSymbolicLink()) { + return [{ code: "symlink-target", file: NIMBUS_JSON, message: `Refusing to write the upgrade baseline through a symbolic link.` }]; + } + const before = exists ? fs.readFileSync(file, "utf8") : null; + if (before !== expectedPreimage) { + return [{ code: "preimage-mismatch", file: NIMBUS_JSON, message: `${NIMBUS_JSON} changed before recording the reviewed version.` }]; + } + const current = readNimbusJson(projectRoot) ?? { + $schema: "https://nimbus-docs.com/schema/nimbus.json", + }; + const next = `${JSON.stringify({ ...current, lastReviewedNimbusVersion: targetVersion }, null, 2)}\n`; + writeFileAtomic(file, next, { overwrite: exists }); + return []; + } catch (error) { + return [{ code: "completion-failed", file: NIMBUS_JSON, message: errorMessage(error) }]; + } +} + +function printUpgradeReviews(reviews: UpgradeEntry[], baseline: Pick): void { + if (!baseline.fromVersion) return; + for (const review of reviews) { + console.log(`${review.id}: review required (${review.introducedIn})`); + console.log(` ${review.summary}`); + console.log(` Affected: ${review.affected}`); + for (const instruction of review.instructions) console.log(` - ${instruction}`); + } +} + +function printCompletionCommand( + reviews: UpgradeEntry[], + baseline: UpgradeBaseline, + completionOptions: CompletionOptions, +): void { + if (!baseline.fromVersion || reviews.length === 0) return; + console.log(`After completing every required review, rerun with consent before project verification:`); + console.log(` ${completionCommand(reviews, baseline, completionOptions)}`); +} + +function completionCommand( + _reviews: UpgradeEntry[], + baseline: UpgradeBaseline, + options: CompletionOptions, +): string { + const cwd = options.cwd ? ` --cwd ${shellQuote(options.cwd)}` : ""; + const srcDir = options.srcDir ? ` --src-dir ${shellQuote(options.srcDir)}` : ""; + const from = baseline.source === "argument" && baseline.fromVersion + ? ` --from ${baseline.fromVersion}` + : ""; + return `nimbus-docs migrate${cwd}${srcDir}${from} --yes`; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function upgradeOrder(a: UpgradeEntry, b: UpgradeEntry): number { + return compare(a.introducedIn, b.introducedIn) || a.id.localeCompare(b.id); +} + +function locationOrder(a: { file: string; line: number; column: number }, b: { file: string; line: number; column: number }): number { + return a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column; +} + +function errorOrder(a: { file?: string; code: string; message: string }, b: { file?: string; code: string; message: string }): number { + return (a.file ?? "").localeCompare(b.file ?? "") || a.code.localeCompare(b.code) || a.message.localeCompare(b.message); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/nimbus-docs/src/cli/nimbus-json.ts b/packages/nimbus-docs/src/cli/nimbus-json.ts index 789fcbc1..d31cc6e6 100644 --- a/packages/nimbus-docs/src/cli/nimbus-json.ts +++ b/packages/nimbus-docs/src/cli/nimbus-json.ts @@ -47,6 +47,7 @@ const nimbusJsonSchema = z .object({ $schema: z.string().optional(), version: z.string().nullable().optional(), + lastReviewedNimbusVersion: z.string().nullable().optional(), templatesTag: z.string().nullable().optional(), variant: z.string().nullable().optional(), registry: z.string().optional(), diff --git a/packages/nimbus-docs/src/cli/upgrade.ts b/packages/nimbus-docs/src/cli/upgrade.ts index 23a0a4f4..dc837cdf 100644 --- a/packages/nimbus-docs/src/cli/upgrade.ts +++ b/packages/nimbus-docs/src/cli/upgrade.ts @@ -5,12 +5,14 @@ * tag — git can't, since that tag was never in your history. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, posix } from "node:path"; +import fs, { existsSync, mkdirSync, readFileSync } from "node:fs"; +import path, { dirname, join, posix } from "node:path"; import * as p from "@clack/prompts"; import { unifiedDiff } from "./_diff.js"; +import { discoverMigrations } from "../_internal/migrations.js"; +import { resolveUpgradeBaseline, selectUpgradeEntries } from "../_internal/upgrades.js"; import { latestTemplatesTag, listTreeFiles, @@ -18,9 +20,10 @@ import { resolveTemplateTree, type FetchedTree, } from "./_templates.js"; -import { bytesHash, readNimbusJson, resolveWriteRoot, type NimbusJson } from "./nimbus-json.js"; +import { bytesHash, readNimbusJson, resolveWriteRoot, type InstalledComponent, type NimbusJson } from "./nimbus-json.js"; import { invocation, updateCommand } from "./pm.js"; import { fetchComponent, type ComponentItem } from "./resolver.js"; +import { writeFileAtomic } from "./fs-atomic.js"; // ── Registry drift (no giget) ────────────────────────────────────────────── @@ -61,7 +64,7 @@ export async function registryDrift( // hand-merge upstream ≠ base, disk ≠ base merge yours→upstream by hand // deleted upstream ≠ base, disk absent upstream changed a file you removed // local upstream = base, disk ≠ base your own edit vs the recorded tag -export type StarterStatus = "clean" | "hand-merge" | "deleted" | "local"; +export type StarterStatus = "clean" | "added" | "removed" | "hand-merge" | "deleted" | "local"; export interface StarterFinding { file: string; // project-relative display path, e.g. src/components/ui/dialog/Dialog.astro @@ -77,30 +80,29 @@ const surfaceOf = (rest: string): string => (rest.includes("/") ? rest.split("/" export function classifyStarter(opts: { srcRoot: string; baseFiles: string[]; + upstreamFiles?: string[]; readBase: (treeFile: string) => string | null; readUpstream: (treeFile: string) => string | null; readDisk: (rest: string) => string | null; }): StarterFinding[] { const out: StarterFinding[] = []; - for (const treeFile of opts.baseFiles) { + const treeFiles = [...new Set([...opts.baseFiles, ...(opts.upstreamFiles ?? opts.baseFiles)])].sort(); + for (const treeFile of treeFiles) { const rest = restOf(treeFile); const base = opts.readBase(treeFile); const upstream = opts.readUpstream(treeFile); const disk = opts.readDisk(rest); - // Already matches upstream (e.g. you ran `diff --apply`) → resolved, not - // drift. Checked first so an applied file leaves the list instead of - // reappearing as a bogus "you edited this". - if (disk !== null && disk === upstream) continue; - - const upstreamChanged = upstream !== base; - const diskDrifted = disk !== null && disk !== base; - if (!upstreamChanged && !diskDrifted) continue; + if (disk === upstream) continue; let status: StarterStatus; - if (upstreamChanged) { - status = disk === null ? "deleted" : disk === base ? "clean" : "hand-merge"; + if (base === null && upstream !== null) { + status = disk === null ? "added" : "hand-merge"; + } else if (base !== null && upstream === null) { + status = disk === base ? "removed" : "hand-merge"; + } else if (upstream === base) { + status = "local"; } else { - status = "local"; // upstream unchanged, disk drifted + status = disk === null ? "deleted" : disk === base ? "clean" : "hand-merge"; } out.push({ file: posix.join(opts.srcRoot, rest), treeFile, surface: surfaceOf(rest), status }); } @@ -123,6 +125,47 @@ export interface UpgradeFlags { templateDir?: string; apply?: boolean; color?: boolean; + json?: boolean; + srcDir?: string; +} + +type OutdatedStatus = "current" | "attention" | "partial" | "failed"; +type LocalRegistryStatus = "clean" | "customized" | "missing" | "unverifiable"; + +interface OutdatedCommand { + bin: string; + args: string[]; + cwd: string; + display: string; +} + +interface OutdatedAction { + kind: "migrate" | "view" | "apply" | "review" | "preserve"; + command?: OutdatedCommand; + automatic: boolean; + instructions: string[]; +} + +export interface OutdatedResult { + schemaVersion: 1; + status: OutdatedStatus; + summary: { packageApis: number; starter: number; registry: number; hiddenContent: number }; + packageApis: Array<{ migrationId: string; locations: string[]; action: OutdatedAction }>; + starter: Array<{ file: string; status: StarterStatus; action: OutdatedAction }>; + registry: Array<{ + slug: string; + upstream: "behind" | "unverified"; + local: LocalRegistryStatus; + files: string[]; + source: string | null; + action: OutdatedAction; + }>; + errors: Array<{ + scope: "package-apis" | "starter" | "registry" | "project"; + code: string; + message: string; + recoverable: boolean; + }>; } interface Gathered { @@ -130,13 +173,16 @@ interface Gathered { baseDir: string; upstreamDir: string; findings: StarterFinding[]; + frameworkNote: string | null; cleanup: () => void; } async function gatherStarter(cwd: string, nimbus: NimbusJson, flags: UpgradeFlags): Promise { const srcRoot = resolveWriteRoot(nimbus); + const unsafeRoot = validateApplyPath(cwd, path.resolve(cwd, srcRoot)); + if (unsafeRoot) throw new Error(`Unsafe starter root: ${unsafeRoot}.`); const recorded = nimbus.templatesTag!; - if (flags.to && flags.templateDir) { + if (flags.to && flags.templateDir && !flags.json) { p.log.warn("--to is ignored with --template-dir (a local checkout has no per-tag content)."); } // Offline (`--template-dir`) has only one local tree, so upstream == base and @@ -157,10 +203,13 @@ async function gatherStarter(cwd: string, nimbus: NimbusJson, flags: UpgradeFlag findings = classifyStarter({ srcRoot, baseFiles: listTreeFiles(base.dir, "src"), + upstreamFiles: listTreeFiles(upstream.dir, "src"), readBase: (t) => readTreeFile(base.dir, t), readUpstream: (t) => readTreeFile(upstream.dir, t), readDisk: (rest) => { const abs = join(cwd, srcRoot, rest); + const unsafe = validateApplyPath(cwd, abs); + if (unsafe) throw new Error(`Unsafe starter path ${rest}: ${unsafe}.`); return existsSync(abs) ? readFileSync(abs, "utf8") : null; }, }); @@ -175,6 +224,7 @@ async function gatherStarter(cwd: string, nimbus: NimbusJson, flags: UpgradeFlag baseDir: base.dir, upstreamDir: upstream.dir, findings, + frameworkNote: frameworkNote(upstream.dir, cwd), cleanup: () => { base.cleanup(); upstream.cleanup(); @@ -186,81 +236,293 @@ async function gatherStarter(cwd: string, nimbus: NimbusJson, flags: UpgradeFlag export async function outdatedCommand(flags: UpgradeFlags): Promise { const cwd = process.cwd(); - const nimbus = requireRecord(cwd); - p.intro("nimbus-docs outdated"); // banner label, not a runnable hint + let result: OutdatedResult; + try { + result = await gatherOutdated(cwd, flags); + } catch (error) { + result = { + schemaVersion: 1, + status: "failed", + summary: { packageApis: 0, starter: 0, registry: 0, hiddenContent: 0 }, + packageApis: [], + starter: [], + registry: [], + errors: [{ scope: "project", code: "outdated-failed", message: errorMessage(error), recoverable: false }], + }; + } + if (flags.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + p.intro("nimbus-docs outdated"); + p.outro(formatOutdatedPretty(result, flags)); + } + process.exitCode = result.status === "partial" || result.status === "failed" ? 1 : 0; +} - const reg = await registryDrift(nimbus, safeFetch); - const behind = reg.filter((r) => r.status === "behind").map(labelWithVersions); - const unverified = reg.filter((r) => r.status === "unverified").map((r) => r.slug); +export async function gatherOutdated(cwd: string, flags: UpgradeFlags = {}): Promise { + const errors: OutdatedResult["errors"] = []; + const packageApis: OutdatedResult["packageApis"] = []; + const starter: OutdatedResult["starter"] = []; + const registry: OutdatedResult["registry"] = []; + let hiddenContent = 0; + let fatal = false; + + const baseline = resolveUpgradeBaseline({ projectRoot: cwd }); + const entries = baseline.fromVersion && !baseline.error + ? selectUpgradeEntries(baseline.fromVersion, baseline.targetVersion) + : []; + const discovery = discoverMigrations({ + projectRoot: cwd, + srcDirOverride: flags.srcDir, + allowUnresolvedLayout: baseline.fromVersion === baseline.targetVersion && !baseline.error, + }); + if (discovery.coverage) { + errors.push({ scope: "package-apis", code: discovery.coverage.code, message: discovery.coverage.message, recoverable: true }); + } + for (const plan of discovery.plans) { + const automatic = plan.blockers.length === 0; + const migrate = selfCommand(cwd, [ + "migrate", + ...(automatic ? ["--yes", "--json"] : ["--print"]), + ...(flags.srcDir ? ["--src-dir", flags.srcDir] : []), + ]); + packageApis.push({ + migrationId: plan.id, + locations: plan.locations.map((location) => `${location.file}:${location.line}:${location.column}`), + action: { kind: "migrate", command: migrate, automatic, instructions: plan.instructions }, + }); + } + if (!baseline.fromVersion || baseline.error) { + errors.push({ + scope: "package-apis", + code: "upgrade-baseline-missing", + message: baseline.error ?? "Nimbus cannot determine the previously reviewed version. Run nimbus-docs migrate --from .", + recoverable: true, + }); + } else { + const activeMigrationIds = new Set(discovery.plans.map((plan) => plan.id)); + for (const entry of entries) { + if (entry.migrationId && activeMigrationIds.has(entry.migrationId)) continue; + packageApis.push({ + migrationId: entry.id, + locations: [], + action: { + kind: "review", + command: selfCommand(cwd, ["migrate", "--print", ...(flags.srcDir ? ["--src-dir", flags.srcDir] : [])]), + automatic: false, + instructions: [...entry.instructions, ...entry.verify], + }, + }); + } + } - const lines: string[] = []; + let nimbus: NimbusJson | null = null; + try { + nimbus = readNimbusJson(cwd); + } catch (error) { + errors.push({ scope: "project", code: "invalid-provenance", message: errorMessage(error), recoverable: false }); + fatal = true; + } - // Starter tier. - if (!nimbus.templatesTag) { - lines.push("Starter: no recorded template tag (adopted via `init`) — starter drift unavailable."); - } else { - let g: Gathered | null = null; + if (!fatal && nimbus) { try { - g = await gatherStarter(cwd, nimbus, flags); - const shown = (f: StarterFinding) => flags.all || !isContent(f.treeFile); - const upstream = g.findings.filter((f) => f.status !== "local" && shown(f)); - const local = g.findings.filter((f) => f.status === "local" && shown(f)).length; - const hiddenContent = g.findings.filter((f) => isContent(f.treeFile) && !flags.all).length; - // Offline (`--template-dir`): upstream == base, so only your own drift can - // surface — say so, or "up to date ✓" reads falsely reassuring. - const offline = flags.templateDir ? " (offline: recorded tag only — no upstream check)" : ""; - - if (upstream.length === 0) { - lines.push( - hiddenContent > 0 - ? `Starter files: up to date${offline} — except ${hiddenContent} content file${hiddenContent === 1 ? "" : "s"} (--all to include)` - : `Starter files: up to date with upstream ✓${offline}`, - ); - } else { - lines.push(`Starter files behind upstream:${offline}`); - for (const [surface, fs] of groupBySurface(upstream)) { - const clean = fs.filter((f) => f.status === "clean").length; - const hand = fs.filter((f) => f.status === "hand-merge").length; - const del = fs.filter((f) => f.status === "deleted").length; - const parts = [ - clean && `${clean} clean to pull`, - hand && `${hand} to hand-merge`, - del && `${del} you removed`, - ].filter(Boolean); - lines.push(` ${surface}: ${parts.join(", ")}`); - } - lines.push(` → \`${invocation("diff ")}\` to view (add \`--apply\` for the clean ones).`); - const note = frameworkNote(g.upstreamDir, cwd); - if (note) lines.push(` ${note}`); - if (hiddenContent > 0) { - lines.push(` (${hiddenContent} content file${hiddenContent === 1 ? "" : "s"} hidden — --all to include)`); - } - } - if (local > 0) { - lines.push(` ${local} starter file${local === 1 ? "" : "s"} you've changed — \`${invocation("diff")}\` to view.`); + const root = path.resolve(cwd, resolveWriteRoot(nimbus)); + const unsafe = validateApplyPath(cwd, root); + if (unsafe) throw new Error(unsafe); + } catch (error) { + errors.push({ scope: "project", code: "unsafe-install-root", message: errorMessage(error), recoverable: false }); + fatal = true; + } + } + + if (!fatal && !nimbus) { + errors.push({ + scope: "project", + code: "no-provenance", + message: "Starter and registry freshness are unavailable because nimbus.json is missing.", + recoverable: true, + }); + } else if (!fatal && nimbus && (!nimbus.templatesTag || nimbus.reconstructed)) { + errors.push({ + scope: "starter", + code: "no-provenance", + message: "Starter freshness is unavailable because nimbus.json has no complete template provenance. Recorded registry items are still checked.", + recoverable: true, + }); + } else if (!fatal && nimbus) { + let gathered: Gathered | null = null; + try { + gathered = await gatherStarter(cwd, nimbus, flags); + const shown = (finding: StarterFinding) => flags.all || !isContent(finding.treeFile); + hiddenContent = gathered.findings.filter((finding) => isContent(finding.treeFile) && !flags.all).length; + for (const finding of gathered.findings.filter(shown)) { + starter.push({ file: finding.file, status: finding.status, action: starterAction(cwd, finding, gathered.frameworkNote) }); } - } catch (err) { - lines.push(`Starter drift skipped: ${(err as Error).message}`); + } catch (error) { + if (!fatal) errors.push({ scope: "starter", code: "starter-unavailable", message: errorMessage(error), recoverable: true }); } finally { - g?.cleanup(); + gathered?.cleanup(); } } - // Registry tier. - if (behind.length > 0) { - lines.push( - "", - `Registry components behind: ${behind.join(", ")}`, - ` → \`${invocation("add --overwrite")}\` to update (review with git).`, - ); - } else { - lines.push("", "Registry components: up to date ✓"); + if (!fatal && nimbus) { + let drift: Awaited> = []; + try { + drift = await registryDrift(nimbus, safeFetch); + } catch (error) { + errors.push({ scope: "registry", code: "registry-unavailable", message: errorMessage(error), recoverable: true }); + } + const records = new Map((nimbus.components ?? []).map((component) => [component.slug, component])); + for (const finding of drift) { + const record = records.get(finding.slug); + registry.push({ + slug: finding.slug, + upstream: finding.status, + local: record ? classifyRegistryLocal(cwd, nimbus, record) : "unverifiable", + files: [...(record?.files ?? [])].sort(), + source: record?.source ?? null, + action: { + kind: "review", + automatic: false, + instructions: ["Review the recorded local files against the current registry source. Preserve project-owned changes; do not automate --overwrite."], + }, + }); + if (finding.status === "unverified") { + errors.push({ scope: "registry", code: "registry-unverified", message: `Could not verify ${finding.slug} against its registry.`, recoverable: true }); + } + } + } + + packageApis.sort((a, b) => a.migrationId.localeCompare(b.migrationId)); + starter.sort((a, b) => a.file.localeCompare(b.file)); + registry.sort((a, b) => a.slug.localeCompare(b.slug)); + errors.sort((a, b) => a.scope.localeCompare(b.scope) || a.code.localeCompare(b.code)); + const attention = packageApis.length > 0 || starter.some((item) => item.status !== "local") || registry.some((item) => item.upstream === "behind"); + const partial = errors.some((error) => error.recoverable); + const status: OutdatedStatus = fatal ? "failed" : partial ? "partial" : attention ? "attention" : "current"; + return { + schemaVersion: 1, + status, + summary: { packageApis: packageApis.length, starter: starter.length, registry: registry.length, hiddenContent }, + packageApis, + starter, + registry, + errors, + }; +} + +function starterAction(cwd: string, finding: StarterFinding, compatibility: string | null): OutdatedAction { + const view = selfCommand(cwd, ["diff", finding.file]); + if (finding.status === "clean" || finding.status === "added" || finding.status === "removed") { + if (compatibility) { + return { + kind: "review", + command: view, + automatic: false, + instructions: [compatibility, "Update Nimbus first, rerun outdated, then apply the file only if it remains clean."], + }; + } + return { + kind: "apply", + command: selfCommand(cwd, ["diff", finding.file, "--apply"]), + automatic: true, + instructions: ["Review the diff, then apply only while the clean preimage or absence still matches."], + }; + } + if (finding.status === "local") { + return { kind: "preserve", command: view, automatic: false, instructions: ["Upstream is unchanged; preserve this project-owned edit unless asked otherwise."] }; + } + return { kind: "review", command: view, automatic: false, instructions: ["Review and reconcile by hand; automatic apply would discard project-owned work."] }; +} + +function selfCommand(cwd: string, args: string[]): OutdatedCommand { + const entry = process.argv[1] ? fs.realpathSync(process.argv[1]) : "nimbus-docs"; + const tokens = [process.execPath, entry, ...args]; + return { bin: process.execPath, args: [entry, ...args], cwd: ".", display: tokens.map(shell).join(" ") }; +} + +function classifyRegistryLocal(cwd: string, nimbus: NimbusJson, component: InstalledComponent): LocalRegistryStatus { + if (component.handAuthored || !component.hash || !component.source || component.files.length === 0) return "unverifiable"; + let root: string; + try { + root = resolveWriteRoot(nimbus).split(path.sep).join("/").replace(/\/+$/, ""); + } catch { + return "unverifiable"; + } + const lexical: Array<{ absolute: string; sourcePath: string }> = []; + for (const recorded of component.files) { + const normalized = recorded.replace(/\\/g, "/"); + if (!root || !normalized.startsWith(`${root}/`) || normalized.includes("/../") || path.posix.isAbsolute(normalized)) return "unverifiable"; + const absolute = path.resolve(cwd, ...normalized.split("/")); + const rel = path.relative(cwd, absolute); + if (rel.startsWith("..") || path.isAbsolute(rel)) return "unverifiable"; + lexical.push({ absolute, sourcePath: normalized.slice(root.length + 1) }); + } + const files: Array<{ path: string; content: string }> = []; + for (const item of lexical) { + let stat: fs.Stats; + try { + stat = fs.lstatSync(item.absolute); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + return "unverifiable"; + } + if (!stat.isFile() || stat.isSymbolicLink()) return "unverifiable"; + try { + const realRoot = fs.realpathSync(cwd); + const realFile = fs.realpathSync(item.absolute); + const rel = path.relative(realRoot, realFile); + if (rel.startsWith("..") || path.isAbsolute(rel)) return "unverifiable"; + files.push({ path: item.sourcePath, content: fs.readFileSync(item.absolute, "utf8") }); + } catch { + return "unverifiable"; + } + } + return bytesHash(files) === component.hash ? "clean" : "customized"; +} + +function formatOutdatedPretty(result: OutdatedResult, flags: UpgradeFlags): string { + const lines: string[] = []; + const unavailable = (scope: OutdatedResult["errors"][number]["scope"]): boolean => result.errors.some((error) => error.scope === scope); + if (result.packageApis.length === 0 && unavailable("package-apis")) lines.push("Package APIs: unavailable"); + else if (result.packageApis.length === 0) lines.push("Package APIs: up to date ✓"); + else { + lines.push(`Package APIs: ${result.packageApis.length} migration${result.packageApis.length === 1 ? "" : "s"} pending`); + for (const item of result.packageApis) lines.push(` ${item.migrationId} → ${item.action.command?.display ?? "nimbus-docs migrate"}`); } - if (unverified.length > 0) { - lines.push(` (couldn't verify ${unverified.join(", ")} — offline?)`); + const upstream = result.starter.filter((item) => item.status !== "local"); + const local = result.starter.filter((item) => item.status === "local"); + if (unavailable("starter") || unavailable("project")) lines.push("", "Starter files: unavailable"); + else if (upstream.length === 0) lines.push("", "Starter files: up to date with upstream ✓"); + else { + lines.push("", "Starter files behind upstream:"); + for (const item of upstream) lines.push(` ${item.file}: ${item.status}`); + lines.push(` → \`${invocation("diff ")}\` to review; --apply is limited to clean/add/remove cases.`); } + if (local.length > 0) lines.push(` ${local.length} local-only starter edit${local.length === 1 ? "" : "s"} preserved.`); + const compatibility = new Set( + result.starter.flatMap((item) => item.action.kind === "review" && item.status !== "hand-merge" ? item.action.instructions.slice(0, 1) : []), + ); + for (const note of compatibility) lines.push(` ${note}`); + if (result.summary.hiddenContent > 0 && !flags.all) lines.push(` (${result.summary.hiddenContent} content file${result.summary.hiddenContent === 1 ? "" : "s"} hidden — --all to include)`); + const behind = result.registry.filter((item) => item.upstream === "behind"); + if (unavailable("registry") || result.status === "failed" || unavailable("project")) lines.push("", "Registry components: unavailable"); + else if (behind.length === 0) lines.push("", "Registry components: up to date ✓"); + else { + lines.push("", "Registry components behind (review only; overwrite is not automated):"); + for (const item of behind) lines.push(` ${item.slug}: ${item.local} locally`); + } + for (const error of result.errors) lines.push("", `${error.scope}: ${error.message}`); + if (flags.templateDir) lines.push("", "Offline template mode compares against the recorded local tag only."); + return lines.join("\n"); +} - p.outro(lines.join("\n")); +function shell(value: string): string { + return /^[A-Za-z0-9@._/:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } // ── `nimbus-docs diff [file]` ────────────────────────────────────────────── @@ -278,6 +540,14 @@ export async function diffCommand(file: string | undefined, flags: UpgradeFlags) const g = await gatherStarter(cwd, nimbus, flags); try { + if (g.frameworkNote) { + p.log.warn(g.frameworkNote); + if (flags.apply) { + p.log.error("Update the Nimbus package and rerun outdated before applying starter files."); + process.exitCode = 1; + return; + } + } const match = (f: StarterFinding): boolean => !file || f.file === file || restOf(f.treeFile) === file || f.file.endsWith(`/${file}`); const targets = g.findings.filter(match).filter((f) => file || flags.all || !isContent(f.treeFile)); @@ -286,7 +556,8 @@ export async function diffCommand(file: string | undefined, flags: UpgradeFlags) if (file && targets.length === 0) { p.log.error(`No change for "${file}" vs the recorded tag. Run \`${invocation("outdated")}\` to list changes.`); - process.exit(1); + process.exitCode = 1; + return; } if (targets.length === 0) { p.log.step("No starter drift. ✓"); @@ -306,22 +577,26 @@ export async function diffCommand(file: string | undefined, flags: UpgradeFlags) label = "your changes vs recorded tag"; left = base ?? ""; right = disk ?? ""; - } else if (upstream === null) { + } else if (f.status === "removed") { label = "upstream removed this file"; left = disk ?? base ?? ""; right = ""; + } else if (f.status === "added") { + label = "upstream added this file"; + left = ""; + right = upstream ?? ""; } else if (f.status === "deleted") { label = "upstream changed a file you removed"; left = base ?? ""; - right = upstream; + right = upstream ?? ""; } else if (f.status === "hand-merge") { label = "you and upstream both diverge from the recorded tag — hand-merge"; left = disk ?? ""; - right = upstream; + right = upstream ?? ""; } else { label = "upstream (clean to pull)"; left = base ?? ""; - right = upstream; + right = upstream ?? ""; } const body = unifiedDiff(left, right, { path: f.file, color }); chunks.push(`\n${label} · ${f.file}`, body || " (differs only in trailing newline / whitespace at end of file)"); @@ -335,14 +610,23 @@ export async function diffCommand(file: string | undefined, flags: UpgradeFlags) function applyOne(cwd: string, file: string | undefined, g: Gathered, targets: StarterFinding[]): void { if (!file) { p.log.error("`diff --apply` needs a specific — it never applies in bulk."); - process.exit(1); + process.exitCode = 1; + return; + } + let target: StarterFinding | null; + try { + target = selectStarterApplyTarget(file, targets); + } catch (error) { + p.log.error(errorMessage(error)); + process.exitCode = 1; + return; } - const target = targets[0]; if (!target) { p.log.error(`No upstream change for "${file}" to apply.`); - process.exit(1); + process.exitCode = 1; + return; } - if (target.status !== "clean") { + if (target.status !== "clean" && target.status !== "added" && target.status !== "removed") { const why = target.status === "hand-merge" ? "you've edited it, so applying upstream would discard your changes" @@ -353,19 +637,73 @@ function applyOne(cwd: string, file: string | undefined, g: Gathered, targets: S `Refusing to --apply ${target.file}: ${why}. ` + `--apply only pulls clean upstream changes — run \`${invocation(`diff ${file}`)}\` and reconcile by hand.`, ); - process.exit(1); - } - const bytes = readTreeFile(g.upstreamDir, target.treeFile); - if (bytes === null) { - p.log.error(`Upstream no longer ships ${target.file}; nothing to apply.`); - process.exit(1); + process.exitCode = 1; + return; } const abs = join(cwd, target.file); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, bytes); + const unsafe = validateApplyPath(cwd, abs); + if (unsafe) { + p.log.error(`Refusing to --apply ${target.file}: ${unsafe}`); + process.exitCode = 1; + return; + } + const base = readTreeFile(g.baseDir, target.treeFile); + const upstream = readTreeFile(g.upstreamDir, target.treeFile); + const disk = existsSync(abs) ? readFileSync(abs, "utf8") : null; + if (target.status === "added") { + if (disk !== null || upstream === null) { + p.log.error(`Refusing to --apply ${target.file}: the path is no longer absent or upstream vanished.`); + process.exitCode = 1; + return; + } + mkdirSync(dirname(abs), { recursive: true }); + writeFileAtomic(abs, upstream, { overwrite: false }); + } else if (target.status === "removed") { + if (base === null || disk !== base || upstream !== null) { + p.log.error(`Refusing to --apply ${target.file}: the clean removal preimage changed.`); + process.exitCode = 1; + return; + } + fs.unlinkSync(abs); + } else { + if (base === null || upstream === null || disk !== base) { + p.log.error(`Refusing to --apply ${target.file}: the clean update preimage changed.`); + process.exitCode = 1; + return; + } + writeFileAtomic(abs, upstream); + } p.log.success(`Applied upstream ${target.file}. Review with \`git diff\`.`); } +function validateApplyPath(cwd: string, target: string): string | null { + const root = path.resolve(cwd); + const absolute = path.resolve(target); + const rel = path.relative(root, absolute); + if (rel.startsWith("..") || path.isAbsolute(rel)) return "path escapes the project"; + let cursor = root; + for (const segment of rel.split(path.sep)) { + cursor = path.join(cursor, segment); + if (!existsSync(cursor)) continue; + try { + if (fs.lstatSync(cursor).isSymbolicLink()) return "path contains a symlink"; + } catch (error) { + return errorMessage(error); + } + } + try { + const realRoot = fs.realpathSync(root); + let parent = path.dirname(absolute); + while (!existsSync(parent) && parent !== root) parent = path.dirname(parent); + const realParent = fs.realpathSync(parent); + const parentRel = path.relative(realRoot, realParent); + if (parentRel.startsWith("..") || path.isAbsolute(parentRel)) return "parent resolves outside the project"; + } catch (error) { + return errorMessage(error); + } + return null; +} + // ── helpers ──────────────────────────────────────────────────────────────── function requireRecord(cwd: string): NimbusJson { @@ -379,6 +717,8 @@ function requireRecord(cwd: string): NimbusJson { function readDisk(cwd: string, srcRoot: string, f: StarterFinding): string | null { const abs = join(cwd, srcRoot, restOf(f.treeFile)); + const unsafe = validateApplyPath(cwd, abs); + if (unsafe) throw new Error(`Unsafe starter path ${f.file}: ${unsafe}.`); return existsSync(abs) ? readFileSync(abs, "utf8") : null; } @@ -411,6 +751,18 @@ function pkgNimbusVersion(pkgPath: string): [number, number, number] | null { } } +export function selectStarterApplyTarget( + file: string, + targets: StarterFinding[], +): StarterFinding | null { + if (targets.length > 1) { + throw new Error( + `"${file}" matches multiple starter files. Pass the exact project-relative path.`, + ); + } + return targets[0] ?? null; +} + function installedNimbusVersion(cwd: string): [number, number, number] | null { const pkgPath = join(cwd, "node_modules", "@cloudflare", "nimbus-docs", "package.json"); if (!existsSync(pkgPath)) return null; @@ -425,13 +777,3 @@ function cmpVersion(a: [number, number, number], b: [number, number, number]): n for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! - b[i]!; return 0; } - -function groupBySurface(findings: StarterFinding[]): [string, StarterFinding[]][] { - const m = new Map(); - for (const f of findings) { - const list = m.get(f.surface) ?? []; - list.push(f); - m.set(f.surface, list); - } - return [...m].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); -} diff --git a/packages/nimbus-docs/src/integration.ts b/packages/nimbus-docs/src/integration.ts index e5729c10..895bb1b0 100644 --- a/packages/nimbus-docs/src/integration.ts +++ b/packages/nimbus-docs/src/integration.ts @@ -91,6 +91,8 @@ import { } from "./_internal/icon-virtual.js"; import { scanCodeBlocks } from "./_internal/scan-code-langs.js"; import { walkFilesSync } from "./_internal/fs-walk.js"; +import { discoverMigrations } from "./_internal/migrations.js"; +import { resolveUpgradeBaseline, selectUpgradeEntries } from "./_internal/upgrades.js"; import { registerAuthoredLinkNormalizer } from "./_internal/authored-link-normalizer.js"; import { clearCodeStyleRegistry, @@ -410,6 +412,8 @@ export function nimbus( let sitemapCustomPages: string[] = []; let sitemapExcludedPaths = new Set(); let sitemapTrailingSlash: "always" | "never" | "ignore" = "ignore"; + let sitemapBareRootUrl: string | null = null; + let sitemapHasResolvedRootPage = false; let building = false; let indexedCollectionsForBuild: string[] = []; let apiCollectionsForBuild: string[] = []; @@ -578,6 +582,8 @@ export function nimbus( sitemapCustomPages = []; sitemapExcludedPaths = new Set(); sitemapTrailingSlash = astroConfig.trailingSlash; + sitemapBareRootUrl = null; + sitemapHasResolvedRootPage = false; // Materialize the resolved lint config so the standalone // `nimbus-docs lint` CLI can read severities authored here. Guarded @@ -1009,6 +1015,13 @@ export function nimbus( config, astroConfig.base, ); + const deploymentRoot = new URL( + astroConfig.base || "/", + config.site, + ); + if (deploymentRoot.pathname !== "/") { + sitemapBareRootUrl = deploymentRoot.href.replace(/\/$/, ""); + } const sitemapIntegration = sitemap({ // Our public `SitemapSerialize` types `changefreq` as a // string-literal union and may return `null` to drop an entry. @@ -1024,9 +1037,13 @@ export function nimbus( ...((sitemapOpts?.customPages || requestRenderingConfigured) && { customPages: sitemapCustomPages, }), - ...((hiddenPrefixes.length > 0 || requestRenderingConfigured) && { + ...((hiddenPrefixes.length > 0 || + requestRenderingConfigured || + sitemapBareRootUrl) && { filter: (url: string) => hiddenFilter(url) && + (!sitemapHasResolvedRootPage || + url !== sitemapBareRootUrl) && !isRequestRouteInventoryPath( new URL(url, config.site).pathname, astroConfig.base, @@ -1090,6 +1107,7 @@ export function nimbus( (source, renderOptions) => authoredLinks.normalizeAuthoredLinks(source, { base: authoredLinkBase, + format: "markdown", sourceId: renderOptions?.fileURL ? fileURLToPath(renderOptions.fileURL) : undefined, @@ -1205,6 +1223,7 @@ export function nimbus( transform: (source, filePath) => authoredLinks.normalizeAuthoredLinks(source, { base: authoredLinkBase, + format: "mdx", sourceId: filePath, }), }), @@ -1313,7 +1332,64 @@ export function nimbus( injectTypes, config: astroConfig, buildOutput, + logger, }) => { + const migrationRoot = astroConfig.root + ? fileURLToPath(astroConfig.root) + : projectRootForBuild; + const migrationSrcDir = astroConfig.srcDir + ? fileURLToPath(astroConfig.srcDir) + : srcDirForBuild; + const migrationDiscovery = + migrationRoot && migrationSrcDir + ? discoverMigrations({ projectRoot: migrationRoot, srcDir: migrationSrcDir }) + : null; + if (migrationDiscovery?.coverage) { + const migrationIds = migrationDiscovery.plans.map((plan) => plan.id).join(", "); + const message = + `Nimbus could not complete package API migration detection (${migrationIds}): ${migrationDiscovery.coverage.message} ` + + "Run `nimbus-docs migrate --src-dir ` from the selected project."; + logger?.error(message); + throw new Error(`nimbus-docs: ${message}`); + } + if (migrationDiscovery && migrationDiscovery.plans.length > 0) { + const details = migrationDiscovery.plans + .flatMap((plan) => + plan.locations.length > 0 + ? plan.locations.map((location) => `${plan.id} at ${location.file}:${location.line}:${location.column}`) + : [plan.id], + ) + .join(", "); + const message = + `Nimbus package API migration required (${details}). ` + + "Run `nimbus-docs migrate` to move route-level partial resolution to `markdown.partialResolver`."; + logger?.error(message); + throw new Error(`nimbus-docs: ${message}`); + } + if (migrationRoot) { + const baseline = resolveUpgradeBaseline({ projectRoot: migrationRoot }); + if (baseline.error) { + const message = `${baseline.error} Run \`nimbus-docs migrate\` to repair the upgrade baseline.`; + logger?.error(message); + throw new Error(`nimbus-docs: ${message}`); + } + if (!baseline.fromVersion && baseline.source !== "preview") { + const message = + "Nimbus has no reviewed upgrade baseline. Run `nimbus-docs migrate --from `, complete every review, then rerun migrate with consent before building."; + logger?.error(message); + throw new Error(`nimbus-docs: ${message}`); + } + if (baseline.fromVersion) { + const reviews = selectUpgradeEntries(baseline.fromVersion, baseline.targetVersion); + if (reviews.length > 0) { + const message = + `Nimbus upgrade review required (${reviews.map((entry) => entry.id).join(", ")}). ` + + "Run `nimbus-docs migrate`, complete every review, then rerun migrate with consent before building."; + logger?.error(message); + throw new Error(`nimbus-docs: ${message}`); + } + } + } outputModeForBuild = buildOutput ?? (astroConfig.output === "server" ? "server" : "static"); @@ -1471,6 +1547,13 @@ export function nimbus( } }, "astro:routes:resolved": ({ routes }) => { + sitemapHasResolvedRootPage = routes.some( + (route) => + route.type === "page" && + [route, ...(route.fallbackRoutes ?? [])].some( + (candidate) => candidate.pathname === "/", + ), + ); resolvedRoutesForBuild = routes.map((r) => ({ pattern: r.pattern, type: r.type, diff --git a/packages/nimbus-docs/src/lint/README.md b/packages/nimbus-docs/src/lint/README.md index 735e43ae..870bf633 100644 --- a/packages/nimbus-docs/src/lint/README.md +++ b/packages/nimbus-docs/src/lint/README.md @@ -9,9 +9,8 @@ src/lint/ diagnostic.ts Diagnostic envelope + RULE_CODES registry (+ diagnostic.schema.json) parse.ts Sätteri mdxToMdast → mdast + unist positions; graceful parse-error capture; findNodeAt helper (position → node) used by adapter rules - zod-adapter.ts ZodError → RuleReport[] (for frontmatter-shape) remark-lint-adapter.ts unified Plugin → RuleReport[] — runs remark-lint rules against - Sätteri's mdast tree; the buy-side counterpart to zod-adapter + Sätteri's mdast tree rule.ts Rule contract (code + run(ctx)) config.ts severity resolution + validateLintOptions (build/lint split, IMPLEMENTED_CODES gate) @@ -43,7 +42,7 @@ pretty caret never drift on multibyte content. The 8 commodity rules (heading hygiene, list/emphasis style, code-block flags, bare URLs) delegate detection to remark-lint via the adapter. The 6 irreducible-core rules (anything that needs Nimbus-specific -knowledge: frontmatter schemas, sidebar truth, components registry, +knowledge: frontmatter directives, sidebar truth, components registry, deploy URL, prompt-prefix conventions) stay hand-rolled. Tests under `test/lint/remark-spike-*.test.ts` verify the adapter boundary. @@ -65,7 +64,7 @@ projects get zero new transitive deps when they install `nimbus-docs`. | Rule | Tier | Detector | Auto-fix | |---|---|---|---| -| `frontmatter-shape` | authoring | hand-rolled (zod-adapter) | — | +| `frontmatter-shape` | authoring | YAML parser | — | | `description-required` | authoring | hand-rolled | — | | `single-h1` | authoring | remark-lint | — | | `heading-hierarchy` | authoring | remark-lint | — | diff --git a/packages/nimbus-docs/src/lint/remark-lint-adapter.ts b/packages/nimbus-docs/src/lint/remark-lint-adapter.ts index 20e19a6e..9836319d 100644 --- a/packages/nimbus-docs/src/lint/remark-lint-adapter.ts +++ b/packages/nimbus-docs/src/lint/remark-lint-adapter.ts @@ -1,6 +1,6 @@ /** * Adapter: run a remark-lint rule against the Sätteri mdast tree, return - * `RuleReport[]`. Mirrors the `zod-adapter` shape — a tiny translation + * `RuleReport[]`. Uses a tiny translation * layer that keeps the diagnostic envelope intact while letting us inherit * remark-lint's battle-tested detector logic. * diff --git a/packages/nimbus-docs/src/lint/rules/frontmatter-shape.ts b/packages/nimbus-docs/src/lint/rules/frontmatter-shape.ts index e9ddee79..d3726678 100644 --- a/packages/nimbus-docs/src/lint/rules/frontmatter-shape.ts +++ b/packages/nimbus-docs/src/lint/rules/frontmatter-shape.ts @@ -1,20 +1,6 @@ -/** - * nimbus/frontmatter-shape — validate frontmatter against the framework's - * content schema via the Zod-to-diagnostic adapter. - * - * Runs in *lenient* (passthrough) mode: it checks the types of the fields - * Nimbus owns (title is a string, draft is a boolean, sidebar.order is a - * number, …) but tolerates user-added fields, because the standalone CLI - * can't yet see a site's extended `content.config.ts` schema. Lint - * directive keys (`nimbusDisableRules`) are stripped before validation — - * they're tooling, not content. - */ +/** nimbus/frontmatter-shape — report malformed YAML before other rules run. */ -import { lenientDocsSchema, lenientPartialsSchema } from "../../schemas.js"; import type { Rule } from "../rule.js"; -import { zodErrorToReports } from "../zod-adapter.js"; - -const LINT_DIRECTIVE_KEYS = ["nimbusDisableRules"]; export const frontmatterShape: Rule = { code: "nimbus/frontmatter-shape", @@ -34,21 +20,7 @@ export const frontmatterShape: Rule = { return; } - const subject: Record = { ...frontmatter }; - for (const key of LINT_DIRECTIVE_KEYS) delete subject[key]; - - const schema = - ctx.file.collection === "partials" - ? lenientPartialsSchema - : lenientDocsSchema; - - const result = schema.safeParse(subject); - if (!result.success) { - const reports = zodErrorToReports(result.error, { - frontmatterRaw: frontmatterRaw ?? "", - frontmatterStartLine, - }); - for (const report of reports) ctx.report(report); - } + // Collection schemas and transforms belong to Astro. The standalone + // linter cannot evaluate a project's content.config safely. }, }; diff --git a/packages/nimbus-docs/src/lint/zod-adapter.ts b/packages/nimbus-docs/src/lint/zod-adapter.ts deleted file mode 100644 index f87a3181..00000000 --- a/packages/nimbus-docs/src/lint/zod-adapter.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Adapter: a Zod error → `RuleReport[]`, with each issue mapped onto the - * line of the offending frontmatter key. Consumed by - * `nimbus/frontmatter-shape`; kept separate so any future rule that - * validates structured data through Zod can route through the same shape. - * - * Typed structurally against the ZodError surface we use (just `issues`) - * so it doesn't pin a specific `astro/zod` version. - */ - -import type { RuleReport } from "./rule.js"; - -interface ZodIssueLike { - path: ReadonlyArray; - message: string; -} - -interface ZodErrorLike { - issues: ReadonlyArray; -} - -/** - * Convert each Zod issue into a report. The position is resolved by - * locating the top-level frontmatter key in the raw YAML; falls back to - * the first frontmatter line when the key can't be found (e.g. a missing - * required field, or a nested path whose root key was omitted). - */ -export function zodErrorToReports( - error: ZodErrorLike, - opts: { frontmatterRaw: string; frontmatterStartLine: number }, -): RuleReport[] { - return error.issues.map((issue) => { - const dottedPath = issue.path - .filter((p): p is string | number => typeof p !== "symbol") - .join("."); - const rootKey = issue.path.find((p): p is string => typeof p === "string"); - const { line, column } = locateKey( - opts.frontmatterRaw, - rootKey, - opts.frontmatterStartLine, - ); - const label = dottedPath.length > 0 ? dottedPath : "(frontmatter)"; - return { message: `${label}: ${issue.message}`, line, column }; - }); -} - -/** - * Find the 1-based source line/column of a top-level YAML key. The column - * points at the start of the key. Returns the frontmatter's first line at - * column 1 when the key isn't present. - */ -function locateKey( - frontmatterRaw: string, - key: string | undefined, - startLine: number, -): { line: number; column: number } { - if (key) { - const rawLines = frontmatterRaw.split("\n"); - const pattern = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*:`); - for (let i = 0; i < rawLines.length; i++) { - const match = rawLines[i]!.match(pattern); - if (match) { - return { line: startLine + i, column: match[1]!.length + 1 }; - } - } - } - return { line: startLine, column: 1 }; -} - -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/packages/nimbus-docs/src/runtime.ts b/packages/nimbus-docs/src/runtime.ts index 66bce76a..d3faf164 100644 --- a/packages/nimbus-docs/src/runtime.ts +++ b/packages/nimbus-docs/src/runtime.ts @@ -1192,6 +1192,7 @@ export async function getDocsPageProps(astro: AstroGlobal): Promise<{ Content: import("astro/runtime/server/index.js").AstroComponentFactory; headings: { depth: number; text: string; slug: string }[]; }> { + rejectRemovedPartialHeadingOptions("getDocsPageProps", arguments.length); const page = await resolveProseRoute<"docs">( astro, PRIMARY_COLLECTION, @@ -1211,6 +1212,7 @@ export async function getDocsPageProps(astro: AstroGlobal): Promise<{ export function getDocsPage( astro: AstroGlobal, ): Promise | Response> { + rejectRemovedPartialHeadingOptions("getDocsPage", arguments.length); return resolveProseRoute( astro, PRIMARY_COLLECTION, @@ -1300,6 +1302,7 @@ export async function getCollectionPageProps( Content: import("astro/runtime/server/index.js").AstroComponentFactory; headings: { depth: number; text: string; slug: string }[]; }> { + rejectRemovedPartialHeadingOptions("getCollectionPageProps", arguments.length); const page = await resolveProseRoute( astro, undefined, @@ -1318,6 +1321,7 @@ export async function getCollectionPageProps( export function getCollectionPage( astro: AstroGlobal, ): Promise | Response> { + rejectRemovedPartialHeadingOptions("getCollectionPage", arguments.length); return resolveProseRoute( astro, undefined, @@ -1326,6 +1330,14 @@ export function getCollectionPage( ); } +function rejectRemovedPartialHeadingOptions(helper: string, argumentCount: number): void { + if (argumentCount <= 1) return; + throw new Error( + `${helper}(Astro, options) was removed in Nimbus 0.13. ` + + "Run `nimbus-docs migrate` to move partialHeadings.resolvePartialId to the integration's markdown.partialResolver option.", + ); +} + // --------------------------------------------------------------------------- // API reference (version-aware routing) // --------------------------------------------------------------------------- diff --git a/packages/nimbus-docs/src/schemas.ts b/packages/nimbus-docs/src/schemas.ts index d8d41c7d..9883e554 100644 --- a/packages/nimbus-docs/src/schemas.ts +++ b/packages/nimbus-docs/src/schemas.ts @@ -180,6 +180,9 @@ function baseDocSchema() { : `"title" must be a string, received ${typeof iss.input}`, }), description: z.string({ error: '"description" must be a string' }).optional(), + nimbusDisableRules: z + .array(z.string(), { error: '"nimbusDisableRules" must be an array of rule codes' }) + .optional(), mode: z .enum(["doc", "custom"], { error: '"mode" must be "doc" or "custom"', diff --git a/packages/nimbus-docs/test/agent-endpoint-lifecycle.test.ts b/packages/nimbus-docs/test/agent-endpoint-lifecycle.test.ts index 8b3fb3fc..723ee0ae 100644 --- a/packages/nimbus-docs/test/agent-endpoint-lifecycle.test.ts +++ b/packages/nimbus-docs/test/agent-endpoint-lifecycle.test.ts @@ -15,9 +15,18 @@ import { pathToFileURL } from "node:url"; import { build } from "astro"; import nimbus from "../src/index.ts"; +import { runningNimbusVersion } from "../src/_internal/upgrades.ts"; const roots: string[] = []; +async function markReviewed(root: string): Promise { + await writeFile( + path.join(root, "nimbus.json"), + `${JSON.stringify({ lastReviewedNimbusVersion: runningNimbusVersion() })}\n`, + "utf8", + ); +} + afterEach(async () => { await Promise.all( roots.splice(0).map((root) => rm(root, { recursive: true })), @@ -29,6 +38,7 @@ test("bakes agent-endpoint assets at astro:build:start for prerendered endpoints path.join(os.tmpdir(), "nimbus-generated-markdown-lifecycle-"), ); roots.push(root); + await markReviewed(root); await symlink( path.resolve(import.meta.dirname, "../node_modules"), path.join(root, "node_modules"), @@ -160,7 +170,10 @@ export async function GET({ props, request }) { ], }); - const markdown = await readFile(path.join(root, "dist/guide/index.md"), "utf8"); + const markdown = await readFile( + path.join(root, "dist/guide/index.md"), + "utf8", + ); assert.match(markdown, /\[Guide\]\(\/docs\/guide\)/); assert.match(markdown, /\*\*Cloud\*\*/); assert.match(markdown, /## Shared/); @@ -178,7 +191,10 @@ export async function GET({ props, request }) { await readFile(path.join(root, "dist/nested/llms.txt"), "utf8"), /Nested/, ); - const llmsFull = await readFile(path.join(root, "dist/llms-full.txt"), "utf8"); + const llmsFull = await readFile( + path.join(root, "dist/llms-full.txt"), + "utf8", + ); assert.match(llmsFull, /# Guide/); assert.match(llmsFull, /## Shared/); assert.match(llmsFull, /\[Root\]\(\/docs\/\)/); @@ -200,8 +216,11 @@ export async function GET({ props, request }) { }); test("does not bake for unrelated Markdown endpoints", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "nimbus-unrelated-markdown-route-")); + const root = await mkdtemp( + path.join(os.tmpdir(), "nimbus-unrelated-markdown-route-"), + ); roots.push(root); + await markReviewed(root); await symlink( path.resolve(import.meta.dirname, "../node_modules"), path.join(root, "node_modules"), diff --git a/packages/nimbus-docs/test/authored-links.test.ts b/packages/nimbus-docs/test/authored-links.test.ts index ed167f20..1755def2 100644 --- a/packages/nimbus-docs/test/authored-links.test.ts +++ b/packages/nimbus-docs/test/authored-links.test.ts @@ -65,11 +65,166 @@ test("normalizes authored Markdown and static JSX links", () => { assert.match(transformed, /\[Fence\]\(\/unchanged\)/); }); +test("parses .md comments and literal braces as Markdown", () => { + const source = ` + +😀 Use {account_id} in /api/{account_id}. + +[Guide](/guide) + +
😀 Native
`; + + assert.equal( + normalizeAuthoredLinks(source, { + base: "/docs", + sourceId: "generated.md", + }), + source + .replace("[Guide](/guide)", "[Guide](/docs/guide)") + .replace("HREF='/native'", "HREF='/docs/native'"), + ); + assert.throws( + () => + normalizeAuthoredLinks( + `${source}\n\n[Escape](/%252e%252e/admin)`, + { base: "/docs", sourceId: "generated.md" }, + ), + /destination escapes its canonical path/, + ); + assert.equal( + normalizeAuthoredLinks( + ``, + { base: "/docs", sourceId: "generated.md" }, + ), + ``, + ); + assert.equal( + normalizeAuthoredLinks( + `Prefix + +
+ + +
`, + { base: "/docs", sourceId: "generated.md" }, + ), + `Prefix + +
+ + +
`, + ); + assert.equal( + normalizeAuthoredLinks(String.raw`Search`, { + base: "/docs", + sourceId: "generated.md", + }), + String.raw`Search`, + ); + assert.equal( + normalizeAuthoredLinks( + `Attribute +Expression`, + { base: "/docs", sourceId: "generated.mdx" }, + ), + `Attribute +Expression`, + ); + for (const source of [ + `Escape`, + `Escape`, + `Escape`, + `Escape`, + `Escape`, + ``, + `
+ +
`, + ]) { + assert.throws( + () => + normalizeAuthoredLinks(source, { + base: "/docs", + sourceId: "generated.md", + }), + /destination escapes its canonical path/, + ); + } + assert.throws( + () => + normalizeAuthoredLinks(``, { + base: "/docs", + sourceId: "generated.mdx", + }), + /destination escapes its canonical path/, + ); + assert.throws( + () => + normalizeAuthoredLinks( + `Escape`, + { base: "/docs", sourceId: "generated.md" }, + ), + /destination escapes its canonical path/, + ); + assert.throws( + () => + normalizeAuthoredLinks(source, { + base: "/docs", + sourceId: "generated.mdx", + }), + /generated\.mdx:1:1: could not parse source/, + ); +}); + +test("parses explicitly programmatic Markdown without a source ID", () => { + const source = "Use {account id}.\n\n[Guide](/guide)"; + assert.equal( + normalizeAuthoredLinks(source, { + base: "/docs", + format: "markdown", + }), + "Use {account id}.\n\n[Guide](/docs/guide)", + ); +}); + test("preserves source at the root base", () => { const source = "[Guide](/guide)"; assert.equal(normalizeAuthoredLinks(source, { base: "/" }), source); }); +test("preserves fenced Markdown inside JSX wrappers without link attributes", () => { + const source = ` + +\`\`\`ts +const value = "{"; +\`\`\` + +`; + assert.equal(normalizeAuthoredLinks(source, { base: "/" }), source); + assert.equal(normalizeAuthoredLinks(source, { base: "/docs" }), source); +}); + +test("normalizes linked JSX wrappers containing fenced Markdown", () => { + const source = ` + +\`\`\`ts +const value = "{"; +\`\`\` + +`; + assert.equal( + normalizeAuthoredLinks(source, { base: "/docs" }), + source.replace('href="/guide"', 'href="/docs/guide"'), + ); +}); + test("fails closed at the root base", () => { assert.throws( () => normalizeAuthoredLinks(" { + assert.equal( + deriveReadiness([ + report({ scope: "env" }), + report({ scope: "structure" }), + report({ + scope: "migrations", + findings: [finding({ scope: "migrations", code: "nimbus/migration", severity: "error" })], + }), + ]), + "blocked", + ); +}); + test("deriveTopStatus: failed on any error, partial on a gap, else passed", () => { assert.equal( deriveTopStatus([report({ findings: [finding({ severity: "error", scope: "types" })] })]), diff --git a/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts b/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts index 151cc199..3b13ff2f 100644 --- a/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts +++ b/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts @@ -99,6 +99,63 @@ export default { integrations: [nimbus({ site: "https://x.dev/a", title: "X" })] assert.equal(r.config.site, "https://x.dev/a"); }); +test("a regex literal before the Nimbus call does not hide the config", () => { + const r = ok( + parse(`${IMPORT} +const quotedValue = /(["'])value\\1/; +const nimbusConfig = { site: "https://x.dev", sidebar: { items: buildItems() } }; +export default { integrations: [nimbus(nimbusConfig, { markdown: true })] };`), + ); + assert.equal(r.config.site, "https://x.dev"); + assert.ok(r.unresolved.includes("sidebar")); +}); + +test("resolves the imported Nimbus call and its lexical config binding", () => { + const r = ok( + parse(`${IMPORT} +function unrelated(nimbus: (value: unknown) => unknown) { + const config = { site: "https://wrong.example" }; + return nimbus(config); +} +const config = { site: "https://right.example" }; +export default { integrations: [nimbus(config)] };`), + ); + assert.equal(r.config.site, "https://right.example"); +}); + +test("rejects multiple imported Nimbus integration calls as ambiguous", () => { + const r = parse(`${IMPORT} +const first = nimbus({ site: "https://one.example" }); +const second = nimbus({ site: "https://two.example" });`); + assert.equal(r.ok, false); + assert.equal((r as { reason: string }).reason, "no-object"); + + const emptyDecoy = parse(`${IMPORT} +const decoy = nimbus({ site: "https://decoy.example" }); +export default { integrations: [nimbus()] };`); + assert.equal(emptyDecoy.ok, false); + assert.equal((emptyDecoy as { reason: string }).reason, "no-object"); +}); + +test("rejects multiple Nimbus default import bindings as ambiguous", () => { + const r = parse(`import first from "@cloudflare/nimbus-docs"; +import second from "@cloudflare/nimbus-docs"; +export default { integrations: [first({ site: "https://one.example" }), second({ site: "https://two.example" })] };`); + assert.equal(r.ok, false); + assert.equal((r as { reason: string }).reason, "no-object"); +}); + +test("rejects mutable or multiply-referenced config bindings", () => { + for (const declaration of [ + `let config = { site: "https://initial.example" };\nconfig = { site: computed };`, + `const config = { site: "https://initial.example" };\nconsume(config);`, + ]) { + const r = parse(`${IMPORT}\n${declaration}\nexport default { integrations: [nimbus(config)] };`); + assert.equal(r.ok, false); + assert.equal((r as { reason: string }).reason, "no-object"); + } +}); + test("missing config file → no-config-file", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-cfg-")); try { diff --git a/packages/nimbus-docs/test/cli-migrate.test.ts b/packages/nimbus-docs/test/cli-migrate.test.ts new file mode 100644 index 00000000..af00ca9a --- /dev/null +++ b/packages/nimbus-docs/test/cli-migrate.test.ts @@ -0,0 +1,524 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { runningNimbusVersion } from "../src/_internal/upgrades.js"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cli = path.join(packageRoot, "src", "cli", "index.ts"); +const tsx = import.meta.resolve("tsx"); +const CURRENT_VERSION = runningNimbusVersion(); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function makeProject(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-cli-migrate-")); + roots.push(root); + fs.mkdirSync(path.join(root, "src", "pages"), { recursive: true }); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ scripts: { build: "astro build" } })); + fs.writeFileSync(path.join(root, "nimbus.json"), `${JSON.stringify({ lastReviewedNimbusVersion: CURRENT_VERSION }, null, 2)}\n`); + fs.writeFileSync( + path.join(root, "astro.config.ts"), + `import { defineConfig } from "astro/config";\nimport nimbus from "@cloudflare/nimbus-docs";\nexport default defineConfig({ integrations: [nimbus({ site: "https://example.com", title: "Docs" }, { markdown: { processor: "keep" } })] });\n`, + ); + fs.writeFileSync( + path.join(root, "src", "pages", "[...slug].astro"), + `---\nimport { getDocsPageProps } from "@cloudflare/nimbus-docs";\nconst page = await getDocsPageProps(Astro, { partialHeadings: { resolvePartialId: ({ file, product }) => {\n if (!file) return undefined;\n return product ? \`${"${product}"}/${"${file}"}\` : file;\n} } });\n---\n

{page.entry.id}

\n`, + ); + return root; +} + +function run(cwd: string, args: string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, ["--import", tsx, cli, ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1", ...env }, + }); +} + +test("migrate plans, diffs, applies, preserves modes, and becomes idempotent", () => { + const root = makeProject(); + const config = path.join(root, "astro.config.ts"); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.chmodSync(config, 0o740); + fs.chmodSync(route, 0o640); + + const dry = run(root, ["migrate", "--dry-run", "--json"]); + assert.equal(dry.status, 1, dry.stderr); + assert.ok(dry.stdout, dry.stderr); + const planned = JSON.parse(dry.stdout); + assert.equal(planned.status, "changes_available"); + assert.equal(planned.migrations[0].id, "partial-resolver-to-markdown"); + assert.equal(planned.migrations[0].state, "available"); + assert.equal(planned.migrations[0].changes.length, 2); + assert.deepEqual(planned.migrations[0].changes.map((change: { outcome: string }) => change.outcome), ["planned", "planned"]); + assert.match(planned.migrations[0].changes[0].diff, /^--- a\/astro\.config\.ts/m); + assert.deepEqual(planned.errors, []); + const repeated = run(root, ["migrate", "--dry-run", "--json"]); + assert.equal(repeated.stdout, dry.stdout); + + const plainDryRun = run(root, ["migrate", "--dry-run"]); + assert.equal(plainDryRun.status, 1, plainDryRun.stderr); + assert.match(plainDryRun.stdout, /@@ -\d+,\d+ \+\d+,\d+ @@/); + + const diff = run(root, ["migrate", "--diff"]); + assert.equal(diff.status, 1, diff.stderr); + assert.match(diff.stdout, /--- a\/astro\.config\.ts/); + assert.match(diff.stdout, /@@ -\d+,\d+ \+\d+,\d+ @@/); + assert.doesNotMatch(diff.stdout, /partial-resolver-to-markdown: available/); + assert.doesNotMatch(fs.readFileSync(config, "utf8"), /partialResolver/); + + const dryDiff = run(root, ["migrate", "--dry-run", "--diff"]); + assert.equal(dryDiff.status, 1, dryDiff.stderr); + assert.match(dryDiff.stdout, /--- a\/astro\.config\.ts/); + assert.equal(fs.readFileSync(config, "utf8").includes("partialResolver"), false); + + const printed = run(root, ["migrate", "--print"]); + assert.equal(printed.status, 0, printed.stderr); + assert.match(printed.stdout, /^# Nimbus migration task/); + assert.match(printed.stdout, /partial-resolver-to-markdown/); + assert.doesNotMatch(printed.stdout, /\x1b\[/); + + const applied = run(root, ["migrate", "--yes", "--json"]); + assert.equal(applied.status, 0, applied.stderr || applied.stdout); + const appliedResult = JSON.parse(applied.stdout); + assert.equal(appliedResult.status, "passed"); + assert.equal(appliedResult.migrations[0].state, "applied"); + assert.deepEqual(appliedResult.migrations[0].changes.map((change: { outcome: string }) => change.outcome), ["applied", "applied"]); + assert.match(fs.readFileSync(config, "utf8"), /partialResolver/); + assert.match(fs.readFileSync(route, "utf8"), /getDocsPageProps\(Astro\)/); + assert.equal(fs.statSync(config).mode & 0o777, 0o740); + assert.equal(fs.statSync(route).mode & 0o777, 0o640); + + const again = run(root, ["migrate", "--dry-run", "--json"]); + assert.equal(again.status, 0, again.stderr); + assert.deepEqual(JSON.parse(again.stdout), { + schemaVersion: 1, + status: "passed", + baseline: { + fromVersion: CURRENT_VERSION, + targetVersion: CURRENT_VERSION, + source: "nimbus-json", + recorded: true, + }, + migrations: [], + reviews: [], + errors: [], + }); +}); + +test("historical jumps stay blocked until a clean consented rerun records the range", () => { + const root = makeProject(); + const nimbusFile = path.join(root, "nimbus.json"); + fs.writeFileSync(nimbusFile, `${JSON.stringify({ lastReviewedNimbusVersion: "0.11.0" }, null, 2)}\n`); + + const planned = run(root, ["migrate", "--json"]); + assert.equal(planned.status, 1, planned.stderr); + const plan = JSON.parse(planned.stdout); + assert.equal(plan.status, "blocked"); + assert.equal(plan.baseline.fromVersion, "0.11.0"); + assert.equal(plan.reviews.length, 9); + assert.equal(plan.migrations[0].state, "available"); + const dryRun = run(root, ["migrate", "--dry-run", "--from", "0.11.0"]); + assert.equal(dryRun.status, 1, dryRun.stderr); + assert.equal(dryRun.stdout.match(/remove-gated-config: review required/g)?.length, 1); + + const applied = run(root, ["migrate", "--yes", "--json"]); + assert.equal(applied.status, 1, applied.stderr); + assert.equal(JSON.parse(applied.stdout).status, "blocked"); + assert.equal(JSON.parse(applied.stdout).baseline.recorded, false); + + const pendingCheck = run(root, ["check", "--migrations", "--json"]); + assert.equal(pendingCheck.status, 1, pendingCheck.stderr); + assert.equal(JSON.parse(pendingCheck.stdout).findings.length, 9); + assert.ok(JSON.parse(pendingCheck.stdout).findings.every((finding: { code: string }) => finding.code === "nimbus/upgrade-review")); + + const completed = run(root, ["migrate", "--yes", "--json"]); + assert.equal(completed.status, 0, completed.stderr); + const result = JSON.parse(completed.stdout); + assert.equal(result.status, "passed"); + assert.equal(result.baseline.recorded, true); + assert.equal(result.reviews.length, 9); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, CURRENT_VERSION); + + const checked = run(root, ["check", "--migrations", "--json"]); + assert.equal(checked.status, 0, checked.stderr); + assert.equal(JSON.parse(checked.stdout).findings.length, 0); +}); + +test("runtime imports cannot bypass migration completion", () => { + const root = makeProject(); + const nimbusFile = path.join(root, "nimbus.json"); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync(nimbusFile, `${JSON.stringify({ lastReviewedNimbusVersion: "0.12.0" }, null, 2)}\n`); + fs.writeFileSync( + route, + `--- +import { getDocsPageProps } from "@cloudflare/nimbus-docs/runtime"; +const page = await getDocsPageProps(Astro, { + partialHeadings: { + resolvePartialId: ({ file, product }) => { + if (!file) return undefined; + return product ? \`${"${product}"}/${"${file}"}\` : file; + }, + }, +}); +--- +

{page.entry.id}

+`, + ); + + const applied = run(root, ["migrate", "--yes", "--json"]); + assert.equal(applied.status, 1, applied.stderr); + assert.equal(JSON.parse(applied.stdout).migrations[0].state, "applied"); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, "0.12.0"); + assert.match(fs.readFileSync(route, "utf8"), /getDocsPageProps\(Astro\)/); + assert.doesNotMatch(fs.readFileSync(route, "utf8"), /partialHeadings|resolvePartialId/); + + const completed = run(root, ["migrate", "--yes", "--json"]); + assert.equal(completed.status, 0, completed.stderr); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, CURRENT_VERSION); +}); + +test("unresolved runtime options block migration completion", () => { + const root = makeProject(); + const nimbusFile = path.join(root, "nimbus.json"); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync(nimbusFile, `${JSON.stringify({ lastReviewedNimbusVersion: "0.12.0" }, null, 2)}\n`); + fs.writeFileSync( + route, + `--- +import { getDocsPageProps } from "@cloudflare/nimbus-docs/runtime"; +import { options } from "../options"; +await getDocsPageProps(Astro, options); +--- +`, + ); + fs.writeFileSync( + path.join(root, "src", "options.ts"), + `export const options = { + partialHeadings: { + resolvePartialId: customResolver, + }, +}; +`, + ); + + const result = run(root, ["migrate", "--yes", "--json"]); + assert.equal(result.status, 1, result.stderr); + assert.equal(JSON.parse(result.stdout).migrations[0].state, "blocked"); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, "0.12.0"); +}); + +test("missing baselines require --from before completion", () => { + const root = makeProject(); + fs.rmSync(path.join(root, "nimbus.json")); + const missing = run(root, ["migrate", "--json"]); + assert.equal(missing.status, 1, missing.stderr); + assert.equal(JSON.parse(missing.stdout).status, "blocked"); + assert.equal(JSON.parse(missing.stdout).baseline.source, "missing"); + const missingCheck = run(root, ["check", "--migrations", "--json"]); + assert.equal(missingCheck.status, 1, missingCheck.stderr); + assert.ok(JSON.parse(missingCheck.stdout).findings.some((finding: { code: string }) => finding.code === "nimbus/upgrade-baseline")); + + fs.writeFileSync(path.join(root, "nimbus.json"), `${JSON.stringify({ lastReviewedNimbusVersion: null }, null, 2)}\n`); + const checked = run(root, ["check", "--migrations", "--json"]); + assert.equal(checked.status, 1, checked.stderr); + assert.equal(JSON.parse(checked.stdout).findings[0].code, "nimbus/upgrade-baseline"); + + const noBaseline = run(root, ["migrate", "--yes", "--json"]); + assert.equal(noBaseline.status, 1, noBaseline.stderr); + assert.equal(JSON.parse(noBaseline.stdout).status, "blocked"); + assert.equal(JSON.parse(fs.readFileSync(path.join(root, "nimbus.json"), "utf8")).lastReviewedNimbusVersion, null); + + const task = run(root, ["migrate", "--cwd", ".", "--src-dir", "src", "--from", "0.11.0", "--print"]); + assert.equal(task.status, 0, task.stderr); + assert.match(task.stdout, /nimbus-docs migrate --cwd '\.' --src-dir 'src' --from 0\.11\.0 --yes/); + assert.match(task.stdout, /rerun with consent before project verification/); +}); + +test("a clean consented run records an empty reviewed range", () => { + const root = makeProject(); + const nimbusFile = path.join(root, "nimbus.json"); + fs.writeFileSync(nimbusFile, `${JSON.stringify({ lastReviewedNimbusVersion: null }, null, 2)}\n`); + + const applied = run(root, ["migrate", "--from", CURRENT_VERSION, "--yes", "--json"]); + assert.equal(applied.status, 1, applied.stderr); + assert.equal(JSON.parse(applied.stdout).status, "blocked"); + assert.equal(JSON.parse(applied.stdout).baseline.recorded, false); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, null); + + const task = run(root, ["migrate", "--from", CURRENT_VERSION, "--print"]); + assert.equal(task.status, 0, task.stderr); + assert.doesNotMatch(task.stdout, /No known Nimbus migrations/); + assert.match(task.stdout, new RegExp(`nimbus-docs migrate --from ${CURRENT_VERSION.replaceAll(".", "\\.")} --yes`)); + + const completed = run(root, ["migrate", "--from", CURRENT_VERSION, "--yes", "--json"]); + assert.equal(completed.status, 0, completed.stderr); + assert.equal(JSON.parse(completed.stdout).status, "passed"); + assert.equal(JSON.parse(completed.stdout).baseline.recorded, true); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, CURRENT_VERSION); +}); + +test("failed completion writes do not claim the baseline was recorded", () => { + const root = makeProject(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync(route, fs.readFileSync(route, "utf8").replace(/, \{ partialHeadings:[\s\S]*\}\);/, ");")); + const target = path.join(root, "baseline.json"); + fs.writeFileSync(target, `${JSON.stringify({ lastReviewedNimbusVersion: "0.11.0" }, null, 2)}\n`); + fs.rmSync(path.join(root, "nimbus.json")); + fs.symlinkSync(target, path.join(root, "nimbus.json")); + + const result = run(root, ["migrate", "--yes", "--json"]); + assert.equal(result.status, 1, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.status, "failed"); + assert.equal(report.baseline.recorded, false); + assert.equal(report.errors[0].code, "symlink-target"); +}); + +test("diff exits nonzero while a clean baseline is still pending", () => { + const root = makeProject(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync(route, fs.readFileSync(route, "utf8").replace(/, \{ partialHeadings:[\s\S]*\}\);/, ");")); + fs.writeFileSync(path.join(root, "nimbus.json"), `${JSON.stringify({ lastReviewedNimbusVersion: null }, null, 2)}\n`); + + const result = run(root, ["migrate", "--from", CURRENT_VERSION, "--diff"]); + assert.equal(result.status, 1, result.stderr); + + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ dependencies: { "@cloudflare/nimbus-docs": "https://pkg.pr.new/@cloudflare/nimbus-docs@123" } }), + ); + fs.writeFileSync(path.join(root, "nimbus.json"), JSON.stringify({ lastReviewedNimbusVersion: null, preview: { pr: "123" } })); + const preview = run(root, ["migrate", "--diff"]); + assert.equal(preview.status, 0, preview.stderr); +}); + +test("check and outdated expose the same pending migration", () => { + const root = makeProject(); + const checked = run(root, ["check", "--migrations", "--json"]); + assert.equal(checked.status, 1, checked.stderr); + assert.ok(checked.stdout, checked.stderr); + const checkResult = JSON.parse(checked.stdout); + assert.equal(checkResult.findings[0].code, "nimbus/migration"); + assert.equal(checkResult.findings[0].migration.id, "partial-resolver-to-markdown"); + assert.equal(checkResult.findings[0].migration.command.cwd, "."); + assert.deepEqual( + new Set(checkResult.findings.map((finding: { file?: string }) => finding.file)), + new Set(["astro.config.ts", "src/pages/[...slug].astro"]), + ); + + const outdated = run(root, ["outdated", "--json"]); + assert.equal(outdated.status, 1, outdated.stderr); + const outdatedResult = JSON.parse(outdated.stdout); + assert.equal(outdatedResult.status, "partial"); + assert.equal(outdatedResult.packageApis[0].migrationId, "partial-resolver-to-markdown"); + assert.equal(outdatedResult.packageApis[0].action.automatic, true); + assert.deepEqual(outdatedResult.packageApis[0].action.command.args.slice(-3), ["migrate", "--yes", "--json"]); + assert.equal(outdatedResult.errors[0].code, "no-provenance"); +}); + +test("outdated treats an unsafe recorded install root as fatal JSON", () => { + const root = makeProject(); + fs.writeFileSync( + path.join(root, "nimbus.json"), + `${JSON.stringify({ install: { root: "../outside" }, components: [] }, null, 2)}\n`, + ); + const result = run(root, ["outdated", "--json"]); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.stderr, ""); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.status, "failed"); + assert.equal(parsed.errors.find((error: { code: string }) => error.code === "unsafe-install-root")?.recoverable, false); +}); + +test("reconstructed starter coverage does not hide verifiable registry output", () => { + const root = makeProject(); + fs.writeFileSync( + path.join(root, "nimbus.json"), + `${JSON.stringify({ reconstructed: true, install: { root: "src" }, components: [{ slug: "mine", type: "registry:ui", source: null, hash: null, files: [], handAuthored: true }] }, null, 2)}\n`, + ); + const result = run(root, ["outdated"]); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /Starter files: unavailable/); + assert.match(result.stdout, /Registry components: up to date/); +}); + +test("invalid migrate output and write flag combinations fail before edits", () => { + const root = makeProject(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + const before = fs.readFileSync(route, "utf8"); + const result = run(root, ["migrate", "--yes", "--dry-run"]); + assert.equal(result.status, 2); + assert.match(result.stderr, /cannot be combined/); + assert.equal(fs.readFileSync(route, "utf8"), before); + + const json = run(root, ["migrate", "--yes", "--dry-run", "--json"]); + assert.equal(json.status, 2); + assert.equal(json.stderr, ""); + assert.equal(JSON.parse(json.stdout).errors[0].code, "invalid-arguments"); + assert.equal(fs.readFileSync(route, "utf8"), before); + + const competingOutput = run(root, ["migrate", "--diff", "--json"]); + assert.equal(competingOutput.status, 2); + assert.equal(competingOutput.stderr, ""); + assert.match(JSON.parse(competingOutput.stdout).errors[0].message, /cannot be combined/); +}); + +test("JSON planning is read-only without explicit consent", () => { + const root = makeProject(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + const before = fs.readFileSync(route, "utf8"); + const result = run(root, ["migrate", "--json"]); + assert.equal(result.status, 1, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.status, "changes_available"); + assert.equal(parsed.migrations[0].state, "available"); + assert.equal(fs.readFileSync(route, "utf8"), before); +}); + +test("unified diffs mark files without terminal newlines", () => { + const root = makeProject(); + for (const file of ["astro.config.ts", "src/pages/[...slug].astro"]) { + const absolute = path.join(root, file); + fs.writeFileSync(absolute, fs.readFileSync(absolute, "utf8").replace(/\n$/, "")); + } + const planned = run(root, ["migrate", "--json"]); + assert.equal(planned.status, 1, planned.stderr); + const changes = JSON.parse(planned.stdout).migrations[0].changes as Array<{ diff: string }>; + assert.ok(changes.every((change) => change.diff.includes("\\ No newline at end of file"))); +}); + +test("TTY mode shows the complete diff and cancellation writes nothing", { skip: process.platform === "win32" }, (context) => { + const root = makeProject(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + const before = fs.readFileSync(route, "utf8"); + const command = [process.execPath, "--import", tsx, cli, "migrate"]; + const scriptArgs = process.platform === "darwin" + ? ["-q", "/dev/null", ...command] + : ["-q", "-c", command.map(shellQuote).join(" "), "/dev/null"]; + const result = spawnSync("script", scriptArgs, { + cwd: root, + encoding: "utf8", + input: "n\n", + env: { ...process.env, NO_COLOR: "1" }, + }); + if (result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT") { + context.skip("script is unavailable"); + return; + } + const output = `${result.stdout}${result.stderr}`; + if (/tcgetattr\/ioctl/.test(output)) { + context.skip("script requires a controlling terminal"); + return; + } + assert.match(output, /@@ -\d+,\d+ \+\d+,\d+ @@/); + assert.match(output, /Apply 1 safe migration/); + assert.equal(fs.readFileSync(route, "utf8"), before); +}); + +test("unresolved srcDir is a blocked migration and invalid cwd is structured", () => { + const root = makeProject(); + const nimbusFile = path.join(root, "nimbus.json"); + fs.writeFileSync(nimbusFile, `${JSON.stringify({ lastReviewedNimbusVersion: "0.12.0" }, null, 2)}\n`); + fs.writeFileSync( + path.join(root, "astro.config.ts"), + `import { defineConfig } from "astro/config";\nimport nimbus from "@cloudflare/nimbus-docs";\nconst srcDir = process.env.SRC;\nexport default defineConfig({ srcDir, integrations: [nimbus({ site: "https://example.com", title: "Docs" })] });\n`, + ); + const unresolved = run(root, ["migrate", "--json"]); + assert.equal(unresolved.status, 1, unresolved.stderr); + const blocked = JSON.parse(unresolved.stdout); + assert.equal(blocked.status, "blocked"); + assert.equal(blocked.migrations[0].id, "partial-resolver-to-markdown"); + assert.equal(blocked.migrations[0].blockers[0].code, "project-layout-unresolved"); + + const invalid = run(root, ["migrate", "--cwd", "../missing", "--json"]); + assert.equal(invalid.status, 1, invalid.stderr); + const failed = JSON.parse(invalid.stdout); + assert.equal(failed.status, "failed"); + assert.equal(failed.errors[0].code, "invalid-project-root"); + + const mismatched = run(root, ["migrate", "--from", "0.12.0", "--json"]); + assert.equal(mismatched.status, 1, mismatched.stderr); + assert.equal(JSON.parse(mismatched.stdout).baseline.recorded, false); + + fs.writeFileSync( + path.join(root, "src", "pages", "[...slug].astro"), + `---\nimport { getDocsPageProps } from "@cloudflare/nimbus-docs";\nconst page = await getDocsPageProps(Astro);\n---\n

{page.entry.id}

\n`, + ); + const completed = run(root, ["migrate", "--src-dir", "src", "--yes", "--json"]); + assert.equal(completed.status, 0, completed.stderr); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, CURRENT_VERSION); + + const idempotent = run(root, ["migrate", "--yes", "--json"]); + assert.equal(idempotent.status, 0, idempotent.stderr); + assert.equal(JSON.parse(idempotent.stdout).status, "passed"); + const checked = run(root, ["check", "--migrations", "--json"]); + assert.equal(checked.status, 0, checked.stderr); + const unsafeOverride = run(root, ["migrate", "--src-dir", "../outside", "--json"]); + assert.equal(unsafeOverride.status, 1, unsafeOverride.stderr); + assert.equal(JSON.parse(unsafeOverride.stdout).migrations[0].blockers[0].code, "project-layout-unresolved"); +}); + +test("unresolved srcDir cannot advance a non-current baseline", () => { + const root = makeProject(); + const nimbusFile = path.join(root, "nimbus.json"); + fs.writeFileSync(nimbusFile, `${JSON.stringify({ lastReviewedNimbusVersion: "0.13.0" }, null, 2)}\n`); + fs.writeFileSync( + path.join(root, "astro.config.ts"), + `import { defineConfig } from "astro/config";\nconst srcDir = process.env.SRC;\nexport default defineConfig({ srcDir });\n`, + ); + fs.writeFileSync( + path.join(root, "src", "pages", "[...slug].astro"), + `---\nimport { getDocsPageProps } from "@cloudflare/nimbus-docs";\nconst page = await getDocsPageProps(Astro);\n---\n`, + ); + + const result = run(root, ["migrate", "--yes", "--json"]); + assert.equal(result.status, 1, result.stderr); + assert.equal(JSON.parse(result.stdout).migrations[0].blockers[0].code, "project-layout-unresolved"); + assert.equal(JSON.parse(fs.readFileSync(nimbusFile, "utf8")).lastReviewedNimbusVersion, "0.13.0"); +}); + +test("blocked output is vendor-neutral and never probes a local agent", () => { + const root = makeProject(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync(route, fs.readFileSync(route, "utf8").replace("return product ?", "return prefix + file || product ?")); + const bin = path.join(root, "bin"); + const calls = path.join(root, "opencode-calls"); + fs.mkdirSync(bin); + const executable = path.join(bin, "opencode"); + fs.writeFileSync(executable, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\nprintf '1.18.29\\n'\n`); + fs.chmodSync(executable, 0o755); + const blocked = run(root, ["migrate", "--json"], { PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }); + assert.equal(blocked.status, 1, blocked.stderr); + const parsed = JSON.parse(blocked.stdout); + assert.equal(parsed.status, "blocked"); + assert.equal(parsed.migrations[0].state, "blocked"); + assert.ok(parsed.migrations[0].blockers.length > 0); + assert.ok(parsed.migrations[0].instructions.length > 0); + assert.equal(fs.existsSync(calls), false); + assert.doesNotMatch(blocked.stdout, /opencode/i); + + const diff = run(root, ["migrate", "--diff"]); + assert.equal(diff.status, 1, diff.stderr); + assert.equal(diff.stdout, ""); + assert.match(diff.stderr, /src\/pages\/\[\.\.\.slug\]\.astro:\d+:\d+/); + assert.match(diff.stderr, /Next steps:/); + + const outdated = run(root, ["outdated", "--json"]); + const packageAction = JSON.parse(outdated.stdout).packageApis[0].action; + assert.equal(packageAction.automatic, false); + assert.deepEqual(packageAction.command.args.slice(-2), ["migrate", "--print"]); +}); + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/packages/nimbus-docs/test/cli-upgrade.test.ts b/packages/nimbus-docs/test/cli-upgrade.test.ts index e4ed6404..a75e8d15 100644 --- a/packages/nimbus-docs/test/cli-upgrade.test.ts +++ b/packages/nimbus-docs/test/cli-upgrade.test.ts @@ -3,7 +3,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { classifyStarter, labelWithVersions, registryDrift } from "../src/cli/upgrade.js"; +import { + classifyStarter, + labelWithVersions, + registryDrift, + selectStarterApplyTarget, +} from "../src/cli/upgrade.js"; import { bytesHash } from "../src/cli/nimbus-json.js"; import type { NimbusJson } from "../src/cli/nimbus-json.js"; import type { ComponentItem, RegistryFile } from "../src/cli/resolver.js"; @@ -70,6 +75,53 @@ test("classifyStarter display path honors a monorepo srcRoot", () => { assert.equal(findings[0]!.file, "packages/docs/src/components/ui/a/A.astro"); }); +test("starter apply rejects an ambiguous suffix", () => { + const finding = (file: string) => ({ + file, + treeFile: file, + surface: "components", + status: "clean" as const, + }); + assert.throws( + () => + selectStarterApplyTarget("Button.astro", [ + finding("src/one/Button.astro"), + finding("src/two/Button.astro"), + ]), + /matches multiple starter files/, + ); + assert.equal( + selectStarterApplyTarget("src/one/Button.astro", [ + finding("src/one/Button.astro"), + ])?.file, + "src/one/Button.astro", + ); +}); + +test("classifyStarter discovers safe upstream additions and removals", () => { + const base = { "src/old.ts": "old", "src/changed.ts": "before" }; + const upstream = { "src/new.ts": "new", "src/changed.ts": "after" }; + const disk: Record = { + "old.ts": "old", + "new.ts": null, + "changed.ts": "before", + }; + const findings = classifyStarter({ + srcRoot: "src", + baseFiles: Object.keys(base), + upstreamFiles: Object.keys(upstream), + readBase: (file) => base[file as keyof typeof base] ?? null, + readUpstream: (file) => upstream[file as keyof typeof upstream] ?? null, + readDisk: (file) => disk[file] ?? null, + }); + const statuses = Object.fromEntries(findings.map((finding) => [finding.treeFile, finding.status])); + assert.deepEqual(statuses, { + "src/changed.ts": "clean", + "src/new.ts": "added", + "src/old.ts": "removed", + }); +}); + test("registryDrift reports version drift (from → to) and labels it", async () => { const current = { ...item("dialog", [{ path: "components/ui/dialog/Dialog.astro", content: "NEW" }]), version: "0.9.0" }; const nimbus: NimbusJson = { diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/astro.config.ts b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/astro.config.ts new file mode 100644 index 00000000..1b17d46c --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/astro.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "astro/config"; +import nimbus, { defineConfig as defineNimbusConfig } from "@cloudflare/nimbus-docs"; + +const config = defineNimbusConfig({ + site: "https://packed-migration.example.test", + title: "Packed migration", + search: false, +}); + +export default defineConfig({ + integrations: [nimbus(config, { + markdown: { hastPlugins: [] }, + validateMdx: false, + sitemap: false, + })], +}); diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/nimbus.json b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/nimbus.json new file mode 100644 index 00000000..3c907118 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/nimbus.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://nimbus-docs.com/schema/nimbus.json", + "lastReviewedNimbusVersion": null, + "reconstructed": true +} diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/package.json b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/package.json new file mode 100644 index 00000000..7a049dad --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/package.json @@ -0,0 +1,17 @@ +{ + "name": "partial-resolver-migration-fixture", + "private": true, + "type": "module", + "scripts": { + "build": "astro build", + "typecheck": "astro check" + }, + "dependencies": { + "@cloudflare/nimbus-docs": "0.13.1", + "astro": "~7.0.9" + }, + "devDependencies": { + "@astrojs/check": "^0.9.8", + "typescript": "^5.8.3" + } +} diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/components.ts b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/components.ts new file mode 100644 index 00000000..e0a0f827 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/components.ts @@ -0,0 +1 @@ +export const components = {}; diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/components/Render.astro b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/components/Render.astro new file mode 100644 index 00000000..5eeeb293 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/components/Render.astro @@ -0,0 +1,10 @@ +--- +import { getEntry, render } from "astro:content"; + +const { file, product } = Astro.props; +const partial = await getEntry("partials", product ? `${product}/${file}` : file); +if (!partial) throw new Error("Missing migration fixture partial"); +const { Content } = await render(partial); +--- + + diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content.config.ts b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content.config.ts new file mode 100644 index 00000000..f95baa84 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from "astro:content"; +import { docsCollection, partialsCollection } from "@cloudflare/nimbus-docs/content"; + +export const collections = { + docs: defineCollection(docsCollection()), + partials: defineCollection(partialsCollection()), +}; diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content/docs/index.mdx b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content/docs/index.mdx new file mode 100644 index 00000000..cfe64410 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content/docs/index.mdx @@ -0,0 +1,9 @@ +--- +title: Packed migration +--- + +import Render from "../../components/Render.astro"; + +# Packed migration + + diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content/partials/product/shared.mdx b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content/partials/product/shared.mdx new file mode 100644 index 00000000..9dcb1b03 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/content/partials/product/shared.mdx @@ -0,0 +1,3 @@ +## Product-prefixed partial heading + +This heading must be merged into the page TOC. diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/pages/[...slug].astro b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/pages/[...slug].astro new file mode 100644 index 00000000..47e0378f --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/src/pages/[...slug].astro @@ -0,0 +1,21 @@ +--- +import { getDocsPageProps, getDocsStaticPaths } from "@cloudflare/nimbus-docs"; + +export const getStaticPaths = getDocsStaticPaths; +const page = await getDocsPageProps(Astro, { + partialHeadings: { + resolvePartialId: ({ file, product }) => { + if (!file) return undefined; + return product ? `${product}/${file}` : file; + }, + }, +}); +const { Content, headings } = page; +--- + + + + +
{JSON.stringify(headings)}
+ + diff --git a/packages/nimbus-docs/test/fixtures/partial-resolver-migration/tsconfig.json b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/tsconfig.json new file mode 100644 index 00000000..bcbf8b50 --- /dev/null +++ b/packages/nimbus-docs/test/fixtures/partial-resolver-migration/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/packages/nimbus-docs/test/integration-dist-invariant.test.ts b/packages/nimbus-docs/test/integration-dist-invariant.test.ts index 64b80f92..ab14e597 100644 --- a/packages/nimbus-docs/test/integration-dist-invariant.test.ts +++ b/packages/nimbus-docs/test/integration-dist-invariant.test.ts @@ -27,6 +27,7 @@ import { pathToFileURL } from "node:url"; import nimbus from "../src/index.js"; import type { RedirectConfigLike } from "../src/_internal/redirect-emitters.js"; import type { ResolvedRouteLike } from "../src/_internal/build-report.js"; +import { runningNimbusVersion } from "../src/_internal/upgrades.js"; const dirUrl = (p: string) => pathToFileURL(p + path.sep); @@ -90,6 +91,10 @@ async function driveBuild( await mkdir(path.dirname(full), { recursive: true }); await writeFile(full, body, "utf8"); }; + await write( + "nimbus.json", + `${JSON.stringify({ lastReviewedNimbusVersion: runningNimbusVersion() })}\n`, + ); await write( "src/content/docs/index.md", "---\ntitle: Home\ndescription: D\n---\n\nHi.\n", @@ -354,10 +359,7 @@ test("project pages and endpoints reach build completion as custom on-demand rou await readFile(path.join(projectRoot, ".nimbus/routes.json"), "utf8"), ); assert.equal(routeTruth.base, "/docs"); - assert.deepEqual( - routeTruth.knownRoutes, - ["/", "/api/ping", "/foo"], - ); + assert.deepEqual(routeTruth.knownRoutes, ["/", "/api/ping", "/foo"]); }); test("unrelated integration routes reach build completion separately", async (t) => { @@ -376,9 +378,7 @@ test("unrelated integration routes reach build completion separately", async (t) }); assert.ok( infos.some((message) => - /integration on-demand routes=1 \(\/integration\/status\)/.test( - message, - ), + /integration on-demand routes=1 \(\/integration\/status\)/.test(message), ), ); assert.deepEqual( diff --git a/packages/nimbus-docs/test/lint/engine.test.ts b/packages/nimbus-docs/test/lint/engine.test.ts index f10f021d..ae5886da 100644 --- a/packages/nimbus-docs/test/lint/engine.test.ts +++ b/packages/nimbus-docs/test/lint/engine.test.ts @@ -57,6 +57,26 @@ no lang assert.deepEqual(lintFile(parse(src)), []); }); +test("frontmatter shape delegates collection schemas and transforms to Astro", () => { + const src = `--- +prev: true +next: true +compatibility_date: 2026-01-01 +--- +`; + const diags = lintFile(parse(src, "compatibility-flags"), { + rules: { "nimbus/frontmatter-shape": "error" }, + }); + assert.ok(!codes(diags).includes("nimbus/frontmatter-shape")); +}); + +test("frontmatter shape still reports malformed YAML", () => { + const diags = lintFile(parse(`---\ntitle: [broken\n---\n`), { + rules: { "nimbus/frontmatter-shape": "error" }, + }); + assert.ok(codes(diags).includes("nimbus/frontmatter-shape")); +}); + test("--rule force-enables a rule that's off by default", () => { // The CLI's --rule= flag would silently print nothing if it just // filtered: every authoring rule starts off. Engine compensates by diff --git a/packages/nimbus-docs/test/markdown-source-ordering.test.ts b/packages/nimbus-docs/test/markdown-source-ordering.test.ts index ea80411f..b441f7d5 100644 --- a/packages/nimbus-docs/test/markdown-source-ordering.test.ts +++ b/packages/nimbus-docs/test/markdown-source-ordering.test.ts @@ -17,6 +17,7 @@ import { build, type AstroIntegration } from "astro"; import { markdownSourcePlugin } from "../src/_internal/markdown-source-vite-plugin.ts"; import { getPreparedMarkdownSnapshot } from "../src/_internal/prepared-markdown-registry.ts"; +import { runningNimbusVersion } from "../src/_internal/upgrades.ts"; import nimbus from "../src/index.ts"; const temporaryRoots: string[] = []; @@ -126,6 +127,11 @@ test("Nimbus production wiring normalizes Markdown and MDX compilation", async ( path.join(os.tmpdir(), "nimbus-authored-integration-"), ); temporaryRoots.push(root); + await writeFile( + path.join(root, "nimbus.json"), + `${JSON.stringify({ lastReviewedNimbusVersion: runningNimbusVersion() })}\n`, + "utf8", + ); await symlink( path.resolve(import.meta.dirname, "../node_modules"), path.join(root, "node_modules"), @@ -140,7 +146,22 @@ import { docsCollection } from ${JSON.stringify( pathToFileURL(path.resolve(import.meta.dirname, "../src/content.ts")) .href, )}; -export const collections = { docs: defineCollection(docsCollection()) };`, +const programmaticLoader = { + name: "programmatic-markdown", + async load(context) { + const body = "Use {account id}.\\n\\n[Programmatic](/guide)"; + context.store.set({ + id: "skill", + data: {}, + body, + rendered: await context.renderMarkdown(body), + }); + }, +}; +export const collections = { + docs: defineCollection(docsCollection()), + programmatic: defineCollection({ loader: programmaticLoader }), +};`, "utf8", ); await writeFile( diff --git a/packages/nimbus-docs/test/migration-transaction.test.ts b/packages/nimbus-docs/test/migration-transaction.test.ts new file mode 100644 index 00000000..831a3391 --- /dev/null +++ b/packages/nimbus-docs/test/migration-transaction.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; + +import type { MigrationPlan } from "../src/_internal/migrations.js"; +import { applyMigrationPlan } from "../src/cli/migrate.js"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +test("a changed preimage refuses the complete edit set", () => { + const { root, plan, files } = fixture(); + fs.writeFileSync(files[1]!, "changed before apply\n"); + const result = applyMigrationPlan(root, undefined, plan); + assert.equal(result.state, "failed"); + assert.equal(result.errors[0]?.code, "preimage-mismatch"); + assert.deepEqual(result.changes.map((change) => change.outcome), ["not_written", "not_written"]); + assert.equal(fs.readFileSync(files[0]!, "utf8"), "before-a\n"); + assert.equal(fs.readFileSync(files[1]!, "utf8"), "changed before apply\n"); +}); + +test("a preimage changed after complete preflight is refused before its write", () => { + const { root, plan, files } = fixture(); + const renameSync = fs.renameSync; + fs.renameSync = ((oldPath: fs.PathLike, newPath: fs.PathLike) => { + renameSync(oldPath, newPath); + if (path.resolve(String(newPath)) === files[0]) fs.writeFileSync(files[1]!, "concurrent edit\n"); + }) as typeof fs.renameSync; + try { + const result = applyMigrationPlan(root, undefined, plan); + assert.equal(result.state, "failed"); + assert.equal(result.errors[0]?.code, "preimage-mismatch"); + assert.deepEqual(result.changes.map((change) => change.outcome), ["applied", "not_written"]); + assert.equal(fs.readFileSync(files[0]!, "utf8"), "after-a\n"); + assert.equal(fs.readFileSync(files[1]!, "utf8"), "concurrent edit\n"); + } finally { + fs.renameSync = renameSync; + } +}); + +test("a target deleted while the atomic temp file is flushed is not recreated", () => { + const { root, plan, files } = fixture(); + const fsyncSync = fs.fsyncSync; + let deleted = false; + fs.fsyncSync = ((fd: number) => { + fsyncSync(fd); + if (!deleted) { + deleted = true; + fs.unlinkSync(files[0]!); + } + }) as typeof fs.fsyncSync; + try { + const result = applyMigrationPlan(root, undefined, plan); + assert.equal(result.state, "failed"); + assert.equal(result.errors[0]?.code, "write-failed"); + assert.equal(fs.existsSync(files[0]!), false); + } finally { + fs.fsyncSync = fsyncSync; + } +}); + +test("an escaping target refuses the complete edit set", () => { + const { root, plan, files } = fixture(); + const outside = path.join(path.dirname(root), `${path.basename(root)}-outside.ts`); + fs.writeFileSync(outside, "outside\n"); + plan.changes[1] = { + file: "../outside.ts", + absoluteFile: outside, + before: "outside\n", + after: "changed\n", + operation: "update", + }; + try { + const result = applyMigrationPlan(root, undefined, plan); + assert.equal(result.state, "failed"); + assert.equal(result.errors[0]?.code, "path-escape"); + assert.equal(fs.readFileSync(files[0]!, "utf8"), "before-a\n"); + assert.equal(fs.readFileSync(outside, "utf8"), "outside\n"); + } finally { + fs.rmSync(outside, { force: true }); + } +}); + +test("a later write failure reports applied and not-written files without rollback", { skip: process.platform === "win32" }, () => { + const { root, plan, files } = fixture(true); + const lockedDirectory = path.dirname(files[1]!); + fs.chmodSync(lockedDirectory, 0o500); + try { + const result = applyMigrationPlan(root, undefined, plan); + assert.equal(result.state, "failed"); + assert.equal(result.errors[0]?.code, "write-failed"); + assert.deepEqual(result.changes.map((change) => change.outcome), ["applied", "not_written"]); + assert.equal(fs.readFileSync(files[0]!, "utf8"), "after-a\n"); + assert.equal(fs.readFileSync(files[1]!, "utf8"), "before-b\n"); + } finally { + fs.chmodSync(lockedDirectory, 0o700); + } +}); + +function fixture(lockSecond = false): { root: string; plan: MigrationPlan; files: string[] } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-migration-write-")); + roots.push(root); + const secondDirectory = lockSecond ? path.join(root, "locked") : root; + fs.mkdirSync(secondDirectory, { recursive: true }); + const files = [path.join(root, "a.ts"), path.join(secondDirectory, "b.ts")]; + fs.writeFileSync(files[0]!, "before-a\n"); + fs.writeFileSync(files[1]!, "before-b\n"); + return { + root, + files, + plan: { + id: "test-migration", + introducedIn: "0.0.0", + summary: "write safety fixture", + locations: [], + blockers: [], + instructions: ["Inspect the failed write."], + changes: [ + { file: "a.ts", absoluteFile: files[0]!, before: "before-a\n", after: "after-a\n", operation: "update" }, + { file: lockSecond ? "locked/b.ts" : "b.ts", absoluteFile: files[1]!, before: "before-b\n", after: "after-b\n", operation: "update" }, + ], + }, + }; +} diff --git a/packages/nimbus-docs/test/migrations.test.ts b/packages/nimbus-docs/test/migrations.test.ts new file mode 100644 index 00000000..5f023b16 --- /dev/null +++ b/packages/nimbus-docs/test/migrations.test.ts @@ -0,0 +1,345 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { pathToFileURL } from "node:url"; + +import { discoverMigrations, resolveMigrationSrcDir } from "../src/_internal/migrations.js"; +import nimbus from "../src/index.js"; +import { + getCollectionPage, + getCollectionPageProps, + getDocsPage, + getDocsPageProps, +} from "../src/runtime.js"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function project(options: { route?: string; config?: string } = {}): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-migrate-")); + roots.push(root); + fs.mkdirSync(path.join(root, "src", "pages"), { recursive: true }); + fs.writeFileSync( + path.join(root, "src", "pages", "[...slug].astro"), + options.route ?? `--- +import { getDocsPageProps } from "@cloudflare/nimbus-docs"; +const page = await getDocsPageProps(Astro, { + partialHeadings: { + resolvePartialId: ({ file, product }) => { + if (!file) return undefined; + return product ? \`${"${product}"}/${"${file}"}\` : file; + }, + }, +}); +--- +

{page.entry.id}

+`, + ); + fs.writeFileSync( + path.join(root, "astro.config.ts"), + options.config ?? `import { defineConfig } from "astro/config"; +import nimbus, { defineConfig as defineNimbusConfig } from "@cloudflare/nimbus-docs"; +const config = defineNimbusConfig({ site: "https://example.com", title: "Docs" }); +export default defineConfig({ + integrations: [nimbus(config, { + markdown: { processor: "keep-me" }, + })], +}); +`, + ); + return root; +} + +test("plans the canonical resolver as two byte-preserving edits", () => { + const root = project(); + const discovery = discoverMigrations({ projectRoot: root }); + assert.equal(discovery.coverage, undefined); + assert.equal(discovery.plans.length, 1); + const plan = discovery.plans[0]!; + assert.deepEqual(plan.blockers, []); + assert.deepEqual(plan.changes.map((change) => change.file), ["astro.config.ts", "src/pages/[...slug].astro"]); + + const config = plan.changes[0]!.after; + assert.match(config, /processor: "keep-me"/); + assert.match(config, /revision: "partial-resolver-v1"/); + assert.match(config, /product \? `\$\{product\}\/\$\{file\}` : file/); + const route = plan.changes[1]!.after; + assert.match(route, /getDocsPageProps\(Astro\)/); + assert.doesNotMatch(route, /partialHeadings|resolvePartialId/); + assert.match(route, /

\{page\.entry\.id\}<\/p>/); +}); + +test("plans the documented expression-bodied resolver", () => { + const root = project(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync( + route, + fs.readFileSync(route, "utf8").replace( + `{\n if (!file) return undefined;\n return product ? \`${"${product}"}/${"${file}"}\` : file;\n }`, + `product ? \`${"${product}"}/${"${file}"}\` : file`, + ), + ); + assert.deepEqual(discoverMigrations({ projectRoot: root }).plans[0]!.blockers, []); +}); + +test("does not globalize a route resolver when another direct prose route exists", () => { + const root = project(); + fs.writeFileSync( + path.join(root, "src", "pages", "other.astro"), + `---\nimport * as docs from "@cloudflare/nimbus-docs/runtime";\nconst page = docs.getDocsPage(Astro);\n---\n`, + ); + const plan = discoverMigrations({ projectRoot: root }).plans[0]!; + assert.deepEqual(plan.changes, []); + assert.ok(plan.blockers.some((blocker) => blocker.code === "multiple-callsites")); +}); + +test("skips customized resolver behavior without proposing edits", () => { + const root = project(); + const route = path.join(root, "src", "pages", "[...slug].astro"); + fs.writeFileSync(route, fs.readFileSync(route, "utf8").replace("return product ?", "return prefix + file || product ?")); + const beforeRoute = fs.readFileSync(route, "utf8"); + const beforeConfig = fs.readFileSync(path.join(root, "astro.config.ts"), "utf8"); + const plan = discoverMigrations({ projectRoot: root }).plans[0]!; + assert.ok(plan.blockers.some((blocker) => blocker.code === "captured-binding")); + assert.deepEqual(plan.changes, []); + assert.equal(fs.readFileSync(route, "utf8"), beforeRoute); + assert.equal(fs.readFileSync(path.join(root, "astro.config.ts"), "utf8"), beforeConfig); +}); + +test("skips dynamic and conflicting integration destinations", () => { + const configs = [ + `import { defineConfig } from "astro/config"; +import nimbus from "@cloudflare/nimbus-docs"; +export default defineConfig({ integrations: [nimbus({}, { ...options })] }); +`, + `import { defineConfig } from "astro/config"; +import nimbus from "@cloudflare/nimbus-docs"; +export default defineConfig({ integrations: [nimbus({}, { markdown: { partialResolver: existing } })] }); +`, + ]; + for (const config of configs) { + const plan = discoverMigrations({ projectRoot: project({ config }) }).plans[0]!; + assert.deepEqual(plan.changes, []); + assert.ok(plan.blockers.some((blocker) => blocker.code === "dynamic-config" || blocker.code === "config-conflict")); + } +}); + +test("identifies an indirect integrations option without rejecting unrelated shorthand", () => { + const root = project({ + config: `import { defineConfig } from "astro/config"; +import nimbus from "@cloudflare/nimbus-docs"; +const markdown = {}; +const integrations = [nimbus({})]; +export default defineConfig({ markdown, integrations }); +`, + }); + const plan = discoverMigrations({ + projectRoot: root, + srcDirOverride: "src", + }).plans[0]!; + assert.deepEqual(plan.changes, []); + assert.equal(plan.blockers[0]?.code, "dynamic-config"); + assert.match(plan.blockers[0]?.message ?? "", /integrations option references an indirect value/); + assert.ok(plan.locations.some((location) => location.file === "astro.config.ts" && location.line === 5)); +}); + +test("blocks only remaining partialHeadings properties in contained source ASTs", () => { + const route = `--- +import { getDocsPageProps } from "@cloudflare/nimbus-docs"; +import { options } from "../options"; +const page = await getDocsPageProps(Astro, options); +--- +

{page.entry.id}

+`; + const currentRoute = route.replace(", options)", ")"); + const cases = [ + { + name: "TypeScript options", + route, + file: "src/options.ts", + source: "export const options = { partialHeadings: {} };\n", + blocker: "unsupported-source", + }, + { + name: "malformed candidate", + route: currentRoute, + file: "src/options.ts", + source: "export const options = { partialHeadings: ;\n", + blocker: "parse-error", + }, + { + name: "Astro markup, comment, and string mentions", + route: currentRoute, + file: "src/pages/example.astro", + source: `---\n// partialHeadings\nconst example = "partialHeadings";\nconst broken = ;\n---\n

partialHeadings

\n`, + }, + { + name: "source outside srcDir", + route: currentRoute, + file: "options.ts", + source: "export const options = { partialHeadings: {} };\n", + }, + ]; + + for (const example of cases) { + const root = project({ route: example.route }); + fs.mkdirSync(path.dirname(path.join(root, example.file)), { recursive: true }); + fs.writeFileSync(path.join(root, example.file), example.source); + const discovery = discoverMigrations({ projectRoot: root }); + if (!example.blocker) { + assert.deepEqual(discovery.plans, [], example.name); + continue; + } + const plan = discovery.plans[0]!; + assert.deepEqual(plan.changes, [], example.name); + assert.ok(plan.blockers.some((blocker) => blocker.code === example.blocker && blocker.file === example.file), example.name); + assert.ok(plan.locations.some((location) => location.file === example.file), example.name); + } +}); + +test("reports symlinked Astro files and route directories as uncertain coverage", () => { + const root = project(); + const canonical = path.join(root, "src", "pages", "[...slug].astro"); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-migrate-symlink-")); + roots.push(outside); + fs.writeFileSync(path.join(outside, "linked.astro"), fs.readFileSync(canonical, "utf8")); + fs.rmSync(canonical); + fs.symlinkSync(path.join(outside, "linked.astro"), canonical); + fs.symlinkSync(outside, path.join(root, "src", "pages", "linked-directory"), "dir"); + const plan = discoverMigrations({ projectRoot: root }).plans[0]!; + assert.deepEqual(plan.changes, []); + assert.equal(plan.blockers.filter((blocker) => blocker.code === "symlink-escape").length, 2); +}); + +test("parses JavaScript config candidates as JavaScript", () => { + const root = project(); + const config = path.join(root, "astro.config.ts"); + const javascript = path.join(root, "astro.config.js"); + fs.renameSync(config, javascript); + fs.writeFileSync(javascript, fs.readFileSync(javascript, "utf8").replace("const config =", "const config: unknown =")); + const discovery = discoverMigrations({ projectRoot: root }); + assert.equal(discovery.coverage?.code, "project-layout-unresolved"); + assert.equal(discovery.plans[0]?.blockers[0]?.code, "project-layout-unresolved"); +}); + +test("preserves CRLF in every planned output", () => { + const root = project(); + for (const file of ["astro.config.ts", "src/pages/[...slug].astro"]) { + const absolute = path.join(root, file); + fs.writeFileSync(absolute, fs.readFileSync(absolute, "utf8").replace(/\n/g, "\r\n")); + } + for (const change of discoverMigrations({ projectRoot: root }).plans[0]!.changes) { + assert.doesNotMatch(change.after, /\r\r\n/); + assert.doesNotMatch(change.after.replace(/\r\n/g, ""), /\n/); + } +}); + +test("computed srcDir requires a contained explicit override", () => { + const root = project({ + config: `import { defineConfig } from "astro/config"; +const srcDir = process.env.SRC; +export default defineConfig({ srcDir }); +`, + }); + assert.match(resolveMigrationSrcDir(root).error ?? "", /--src-dir/); + assert.equal(resolveMigrationSrcDir(root, "src").srcDir, path.join(root, "src")); + assert.match(resolveMigrationSrcDir(root, "missing").error ?? "", /Could not resolve --src-dir/); + assert.match(resolveMigrationSrcDir(root, "../outside").error ?? "", /inside/); +}); + +test("refuses a source tree that resolves outside the project", () => { + const root = project(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-migrate-outside-")); + roots.push(outside); + fs.rmSync(path.join(root, "src"), { recursive: true }); + fs.symlinkSync(outside, path.join(root, "src"), "dir"); + const discovery = discoverMigrations({ projectRoot: root }); + assert.equal(discovery.coverage?.code, "project-layout-unresolved"); +}); + +test("Astro integration reports the shared migration ID and location", () => { + const root = project(); + const errors: string[] = []; + const integration = nimbus({ site: "https://example.com", title: "Docs" } as never, { + validateMdx: false, + admonitions: false, + sitemap: false, + markdown: { processor: {} as never }, + }); + const hook = integration.hooks["astro:config:done"]; + assert.ok(hook); + assert.throws( + () => hook!({ + config: { + root: pathToFileURL(`${root}${path.sep}`), + srcDir: pathToFileURL(`${path.join(root, "src")}${path.sep}`), + output: "static", + redirects: {}, + }, + injectTypes: () => {}, + logger: { error: (message: string) => errors.push(message) }, + } as never), + /nimbus-docs migrate/, + ); + assert.match(errors[0] ?? "", /partial-resolver-to-markdown/); + assert.match(errors[0] ?? "", /src\/pages\/\[\.\.\.slug\]\.astro/); +}); + +test("Astro integration blocks a stable project with no reviewed baseline", () => { + const root = project({ + route: `--- +import { getDocsPageProps } from "@cloudflare/nimbus-docs"; +const page = await getDocsPageProps(Astro); +--- +

{page.entry.id}

+`, + config: `import { defineConfig } from "astro/config"; +import nimbus from "@cloudflare/nimbus-docs"; +export default defineConfig({ integrations: [nimbus({ site: "https://example.com", title: "Docs" })] }); +`, + }); + const integration = nimbus({ site: "https://example.com", title: "Docs" } as never, { + validateMdx: false, + admonitions: false, + sitemap: false, + markdown: { processor: {} as never }, + }); + const hook = integration.hooks["astro:config:done"]; + assert.ok(hook); + assert.throws( + () => hook!({ + config: { + root: pathToFileURL(`${root}${path.sep}`), + srcDir: pathToFileURL(`${path.join(root, "src")}${path.sep}`), + output: "static", + redirects: {}, + }, + injectTypes: () => {}, + logger: { error: () => {} }, + } as never), + /no reviewed upgrade baseline/, + ); +}); + +test("runtime tombstones reject removed partial resolver options on every prose helper", async () => { + await assert.rejects( + (getDocsPageProps as (...args: unknown[]) => Promise)({}, {}), + /nimbus-docs migrate/, + ); + assert.throws( + () => (getDocsPage as (...args: unknown[]) => Promise)({}, {}), + /nimbus-docs migrate/, + ); + await assert.rejects( + (getCollectionPageProps as (...args: unknown[]) => Promise)({}, {}), + /nimbus-docs migrate/, + ); + assert.throws( + () => (getCollectionPage as (...args: unknown[]) => Promise)({}, {}), + /nimbus-docs migrate/, + ); +}); diff --git a/packages/nimbus-docs/test/packed-migration.test.ts b/packages/nimbus-docs/test/packed-migration.test.ts new file mode 100644 index 00000000..3e8c4672 --- /dev/null +++ b/packages/nimbus-docs/test/packed-migration.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixture = path.join(packageRoot, "test", "fixtures", "partial-resolver-migration"); + +test("published failure becomes a packed migration and preserves partial-heading behavior", { timeout: 240_000 }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-packed-migration-")); + const previousNpmrc = process.env.NPM_CONFIG_USERCONFIG; + const emptyNpmrc = path.join(root, "empty-npmrc"); + fs.writeFileSync(emptyNpmrc, ""); + process.env.NPM_CONFIG_USERCONFIG = emptyNpmrc; + try { + fs.cpSync(fixture, root, { recursive: true }); + successful(root, ["install", "--ignore-workspace"]); + + const published = run(root, ["exec", "astro", "check"]); + assert.notEqual(published.status, 0, published.output); + assert.doesNotMatch(published.output, /partial-resolver-to-markdown/); + assert.match(published.output, /getDocsPageProps|Expected 1 arguments, but got 2/); + + const packed = successful(packageRoot, ["pack", "--pack-destination", root]); + const tarball = packed.stdout.trim().split(/\r?\n/).at(-1); + assert.ok(tarball && tarball.endsWith(".tgz"), packed.output); + const manifestPath = path.join(root, "package.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { dependencies: Record }; + manifest.dependencies["@cloudflare/nimbus-docs"] = `file:${tarball}`; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + successful(root, ["install", "--ignore-workspace", "--force", "--no-frozen-lockfile"]); + + const candidate = run(root, ["exec", "astro", "check"]); + assert.notEqual(candidate.status, 0, candidate.output); + assert.match(candidate.output, /partial-resolver-to-markdown/); + assert.match(candidate.output, /nimbus-docs migrate/); + + const applied = run(root, ["exec", "nimbus-docs", "migrate", "--yes", "--from", "0.11.0", "--json"]); + assert.notEqual(applied.status, 0, applied.output); + assert.equal(JSON.parse(applied.stdout).status, "blocked"); + assert.equal(JSON.parse(applied.stdout).migrations[0].state, "applied"); + assert.equal(JSON.parse(applied.stdout).reviews.length, 9); + + const reviewBlocked = run(root, ["exec", "astro", "check"]); + assert.notEqual(reviewBlocked.status, 0, reviewBlocked.output); + assert.match(reviewBlocked.output, /Nimbus has no reviewed upgrade baseline/); + assert.match(reviewBlocked.output, /rerun.*migrate.*before building/); + + const completed = successful(root, [ + "exec", + "nimbus-docs", + "migrate", + "--from", + "0.11.0", + "--yes", + "--json", + ]); + assert.equal(JSON.parse(completed.stdout).status, "passed"); + const repeated = successful(root, ["exec", "nimbus-docs", "migrate", "--dry-run", "--json"]); + assert.equal(JSON.parse(repeated.stdout).status, "passed"); + successful(root, ["exec", "nimbus-docs", "check", "--json"]); + successful(root, ["exec", "astro", "check"]); + successful(root, ["run", "build"]); + + const html = fs.readFileSync(path.join(root, "dist", "index.html"), "utf8"); + assert.match(html, /id="product-prefixed-partial-heading"/); + assert.match(html, /id="headings"[^>]*>[^<]*product-prefixed-partial-heading/); + } finally { + if (previousNpmrc === undefined) delete process.env.NPM_CONFIG_USERCONFIG; + else process.env.NPM_CONFIG_USERCONFIG = previousNpmrc; + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function successful(cwd: string, args: string[]) { + const result = run(cwd, args); + assert.equal(result.status, 0, result.output); + return result; +} + +function run(cwd: string, args: string[]) { + const result = spawnSync("pnpm", args, { + cwd, + encoding: "utf8", + env: { ...process.env, CI: "", NO_COLOR: "1" }, + timeout: 220_000, + }); + return { ...result, output: `${result.stdout}${result.stderr}` }; +} diff --git a/packages/nimbus-docs/test/rendering-policy.test.ts b/packages/nimbus-docs/test/rendering-policy.test.ts index d7835787..820dd20f 100644 --- a/packages/nimbus-docs/test/rendering-policy.test.ts +++ b/packages/nimbus-docs/test/rendering-policy.test.ts @@ -38,6 +38,7 @@ import { requestInventoryVersionStatusKey, } from "../src/_internal/request-route-url.js"; import { validateNimbusConfig } from "../src/_internal/validate.js"; +import { runningNimbusVersion } from "../src/_internal/upgrades.js"; import type { NimbusConfig, RenderingConfig } from "../src/types.js"; const baseConfig = (rendering?: RenderingConfig): NimbusConfig => ({ @@ -282,6 +283,7 @@ async function setupIntegration( } = {}, base = "", trailingSlash: "always" | "never" | "ignore" = "ignore", + buildFormat: "directory" | "file" = "directory", ) { const root = await mkdtemp(path.join(tmpdir(), "nimbus-rendering-policy-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -291,6 +293,10 @@ async function setupIntegration( await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, body, "utf8"); }; + await write( + "nimbus.json", + `${JSON.stringify({ lastReviewedNimbusVersion: runningNimbusVersion() })}\n`, + ); await write("src/content.config.ts", contentConfig); await write("src/components.ts", "export const components = {};\n"); const { omitCanonicalDocsRoute = false, ...options } = integrationOptions; @@ -335,6 +341,7 @@ async function setupIntegration( cacheDir: pathToFileURL(`${path.join(root, ".cache")}${path.sep}`), base, trailingSlash, + build: { format: buildFormat }, }, logger: { info: () => {}, @@ -432,26 +439,38 @@ async function generateRequestSitemap( sitemapOptions: NonNullable = {}, base = "/", trailingSlash: "always" | "never" | "ignore" = "ignore", + buildFormat: "directory" | "file" = "directory", + sitemapRoutes: readonly Record[] = [], + renderingMode: "build" | "request" = "request", ) { const integration = await setupIntegration( t, - { collections: { docs: "request" } }, + renderingMode === "request" + ? { collections: { docs: "request" } } + : undefined, "build", undefined, undefined, { sitemap: sitemapOptions }, base, trailingSlash, + buildFormat, ); await integration.routeSetup({ route: { component: "src/pages/[...slug].astro", prerender: true }, } as never); integration.configDone({ injectTypes: () => new URL("file:///noop"), - config: { output: "server", adapter: { name: "cloudflare" } }, - buildOutput: "server", + config: + renderingMode === "request" + ? { output: "server", adapter: { name: "cloudflare" } } + : { output: "static", adapter: null }, + buildOutput: renderingMode === "request" ? "server" : "static", } as never); - const routes = resolvedNimbusRoutes(integration.injectedRoutes, "request"); + const routes = [ + ...resolvedNimbusRoutes(integration.injectedRoutes, renderingMode), + ...sitemapRoutes, + ]; integration.routesResolved({ routes } as never); const sitemapIntegration = integration.configUpdates @@ -471,11 +490,11 @@ async function generateRequestSitemap( site: "https://example.test", base, trailingSlash, - build: { format: "directory" }, + build: { format: buildFormat }, }, } as never); await sitemapIntegration.hooks["astro:routes:resolved"]?.({ - routes: [], + routes: sitemapRoutes, } as never); const dist = path.join(integration.root, "dist"); @@ -541,15 +560,115 @@ test("mixed sitemap includes prerendered and request-rendered pages", async (t) assert.match(xml, /https:\/\/example\.test\/runtime\/<\/loc>/); }); -test("sitemap deduplicates the deployment root across trailing slash forms", async (t) => { +test("sitemap deduplicates the deployment root across trailing slash policies", async (t) => { + const rootRoute = { + pattern: "/", + entrypoint: "src/pages/index.astro", + type: "page", + pathname: "/", + generate: () => "/", + fallbackRoutes: [], + isPrerendered: true, + origin: "project", + }; + for (const { base, trailingSlash, buildFormat, expected } of [ + { + base: "/docs", + trailingSlash: "ignore", + buildFormat: "directory", + expected: "https://example.test/docs/", + }, + { + base: "/docs/", + trailingSlash: "always", + buildFormat: "directory", + expected: "https://example.test/docs/", + }, + { + base: "/docs", + trailingSlash: "never", + buildFormat: "directory", + expected: "https://example.test/docs", + }, + { + base: "/docs", + trailingSlash: "ignore", + buildFormat: "file", + expected: "https://example.test/docs", + }, + ] as const) { + const serialized: string[] = []; + const xml = await generateRequestSitemap( + t, + [], + [{ pathname: "" }], + { + serialize: ({ url }) => { + serialized.push(url); + return { url }; + }, + }, + base, + trailingSlash, + buildFormat, + [rootRoute], + "build", + ); + + assert.equal(serialized.length, 1); + assert.equal( + xml.match(/https:\/\/example\.test\/docs\/?<\/loc>/g)?.length, + 1, + ); + assert.match( + xml, + new RegExp(`${expected.replaceAll("/", "\\/")}<\\/loc>`), + ); + } +}); + +test("sitemap preserves a custom-only bare deployment root", async (t) => { + const xml = await generateRequestSitemap( + t, + [], + [], + { customPages: ["https://example.test/docs"] }, + "/docs", + ); + + assert.match(xml, /https:\/\/example\.test\/docs<\/loc>/); +}); + +test("sitemap deduplicates a request-rendered root against its resolved route", async (t) => { + const serialized: string[] = []; const xml = await generateRequestSitemap( t, [{ collection: "docs", url: "/", request: true, discoverable: true }], - [{ pathname: "" }], - {}, + [], + { + serialize: ({ url }) => { + serialized.push(url); + return { url }; + }, + }, "/docs", + "never", + "directory", + [ + { + pattern: "/", + entrypoint: "src/pages/index.astro", + type: "page", + pathname: "/", + generate: () => "/", + fallbackRoutes: [], + isPrerendered: false, + origin: "project", + }, + ], ); + assert.equal(serialized.length, 1); assert.equal( xml.match(/https:\/\/example\.test\/docs\/?<\/loc>/g)?.length, 1, @@ -575,8 +694,7 @@ test("sitemap deduplicates mixed routes for every trailing slash policy", async ); assert.equal( - xml.match(/https:\/\/example\.test\/docs\/guide\/?<\/loc>/g) - ?.length, + xml.match(/https:\/\/example\.test\/docs\/guide\/?<\/loc>/g)?.length, 1, ); diff --git a/packages/nimbus-docs/test/schema-frontmatter.test.ts b/packages/nimbus-docs/test/schema-frontmatter.test.ts new file mode 100644 index 00000000..c138da5b --- /dev/null +++ b/packages/nimbus-docs/test/schema-frontmatter.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { docsSchema } from "../src/schemas.js"; + +test("the docs schema accepts Nimbus lint-disable frontmatter", () => { + const result = docsSchema.safeParse({ + title: "Test", + nimbusDisableRules: ["nimbus/single-h1"], + }); + assert.equal(result.success, true); +}); diff --git a/packages/nimbus-docs/test/upgrades.test.ts b/packages/nimbus-docs/test/upgrades.test.ts new file mode 100644 index 00000000..6374f217 --- /dev/null +++ b/packages/nimbus-docs/test/upgrades.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { MIGRATION_CATALOG } from "../src/_internal/migrations.js"; +import { + installedNimbusVersion, + resolveUpgradeBaseline, + runningNimbusVersion, + selectUpgradeEntries, + UPGRADE_MANIFEST, +} from "../src/_internal/upgrades.js"; + +test("source execution reads the current package version", () => { + const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }; + assert.equal(runningNimbusVersion(), packageJson.version); +}); + +test("every automatic manifest entry has a matching codemod", () => { + const automatic = UPGRADE_MANIFEST.entries.filter((entry) => entry.mode === "automatic"); + assert.deepEqual( + automatic.map((entry) => [entry.migrationId, entry.introducedIn]).sort(), + MIGRATION_CATALOG.map((entry) => [entry.id, entry.introducedIn]).sort(), + ); +}); + +test("upgrade guidance targets canonical agent endpoint APIs", () => { + const instructions = (id: string) => + UPGRADE_MANIFEST.entries.find((entry) => entry.id === id)?.instructions.join("\n") ?? ""; + const markdown = instructions("prepared-markdown-artifacts"); + assert.match(markdown, /getMarkdownStaticPaths/); + assert.match(markdown, /slug: params\.slug, reference: props\.reference, context: \{ request \}/); + assert.match(markdown, /null payload/); + + const llms = instructions("llms-full-prepared-artifact"); + assert.match(llms, /getLlmsPayload\(\{ scope: "site", surface: "full" \}, \{ request \}\)/); + assert.match(llms, /null payload/); + + const partialResolver = instructions("partial-resolver-to-markdown"); + assert.match(partialResolver, /revision: "partial-resolver-v1"/); + assert.match( + partialResolver, + /resolve: \(\{ file, product \}\) => product \? `\$\{product\}\/\$\{file\}` : file/, + ); + + const renames = instructions("prepared-publication-api-renames"); + for (const helper of [ + "getPreparedTwinStaticPaths", + "getPreparedTwinArtifact", + "getPreparedMarkdownStaticPaths", + "getPreparedMarkdownArtifact", + "getPreparedCorpusStaticPaths", + "getPreparedCorpusArtifact", + "getPreparedLlmsStaticPaths", + "getPreparedLlmsArtifact", + "getPreparedMarkdownRouteStaticPaths", + "getPreparedMarkdownRouteArtifact", + "getPreparedLlmsRouteStaticPaths", + "getPreparedLlmsRouteArtifact", + ]) { + assert.match(renames, new RegExp(`\\b${helper}\\b`)); + } + assert.match( + renames, + /getPreparedMarkdownArtifact\(reference\).*getMarkdownPayload\(\{ collection: reference\.collection, surface: reference\.surface, reference, context: \{ request \} \}\)/, + ); + assert.match( + renames, + /getPreparedMarkdownRouteArtifact\(options\) to getMarkdownPayload\(options\)/, + ); + assert.match( + renames, + /getPreparedLlmsArtifact\(reference\).*getLlmsPayload\(reference, \{ request \}\)/, + ); + assert.match(renames, /props\.reference \?\? \(params\.section/); + assert.match(renames, /scope: "section", surface: "index", section: params\.section/); + assert.match(renames, /return a 404 response when reference is null/); + for (const type of [ + "TwinSurface", + "PreparedMarkdownSurface", + "PreparedTwinReference", + "PreparedMarkdownReference", + "PreparedTwinArtifact", + "PreparedMarkdownArtifact", + "PreparedCorpusReference", + "PreparedLlmsReference", + "PreparedCorpusArtifact", + "PreparedLlmsArtifact", + ]) { + assert.match(renames, new RegExp(`\\b${type}\\b`)); + } + assert.match(renames, /PreparedMarkdownReference to MarkdownEndpointReference/); + assert.match(renames, /PreparedMarkdownArtifact to MarkdownEndpointPayload/); + assert.match(renames, /PreparedLlmsReference to LlmsEndpointReference/); + assert.match(renames, /PreparedLlmsArtifact to LlmsEndpointPayload/); + assert.match(renames, /nullable result/); + + const allInstructions = UPGRADE_MANIFEST.entries.flatMap((entry) => entry.instructions).join("\n"); + assert.doesNotMatch(allInstructions, /@cloudflare\/nimbus-docs\/build/); +}); + +test("selectUpgradeEntries composes the open-closed version range", () => { + assert.deepEqual( + selectUpgradeEntries("0.11.0", "0.12.9").map((entry) => entry.id), + ["remove-gated-config"], + ); + assert.deepEqual( + selectUpgradeEntries("0.11.0", "0.13.0").map((entry) => entry.id), + [ + "remove-gated-config", + "index-route-normalization", + "llms-full-prepared-artifact", + "logical-authored-links", + "partial-resolver-to-markdown", + "prepared-markdown-artifacts", + "prepared-publication-api-renames", + "twins-config-to-markdown", + "with-base-route-to-with-base", + ], + ); + assert.equal(selectUpgradeEntries("0.13.0", "0.13.1").length, 0); + assert.equal(selectUpgradeEntries("0.13.0", "0.14.0").length, 0); + assert.equal(selectUpgradeEntries("0.13.1", "0.14.0").length, 0); +}); + +test("selectUpgradeEntries rejects unsupported and reversed ranges", () => { + assert.throws(() => selectUpgradeEntries("0.10.0", "0.13.1"), /predates the complete manifest/); + assert.throws(() => selectUpgradeEntries("0.14.0", "0.13.1"), /newer than installed/); + assert.throws(() => selectUpgradeEntries("next", "0.13.1"), /Invalid upgrade baseline/); +}); + +test("resolveUpgradeBaseline prefers --from and validates persisted baselines", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-upgrades-")); + try { + fs.writeFileSync(path.join(root, "nimbus.json"), JSON.stringify({ lastReviewedNimbusVersion: "0.12.0" })); + assert.deepEqual(resolveUpgradeBaseline({ projectRoot: root, targetVersion: "0.13.1" }), { + fromVersion: "0.12.0", + targetVersion: "0.13.1", + source: "nimbus-json", + }); + assert.deepEqual(resolveUpgradeBaseline({ projectRoot: root, fromVersion: "0.13.0", targetVersion: "0.13.1" }), { + fromVersion: "0.13.0", + targetVersion: "0.13.1", + source: "argument", + error: "--from 0.13.0 does not match the recorded Nimbus baseline 0.12.0.", + }); + assert.equal(resolveUpgradeBaseline({ projectRoot: root, fromVersion: "0.12.0", targetVersion: "0.13.1" }).error, undefined); + + fs.writeFileSync(path.join(root, "nimbus.json"), JSON.stringify({ lastReviewedNimbusVersion: "0.10.0" })); + assert.match(resolveUpgradeBaseline({ projectRoot: root, targetVersion: "0.13.1" }).error ?? "", /oldest supported baseline/); + + fs.writeFileSync(path.join(root, "nimbus.json"), JSON.stringify({ lastReviewedNimbusVersion: null })); + assert.equal(resolveUpgradeBaseline({ projectRoot: root, targetVersion: "0.13.1" }).source, "nimbus-json"); + fs.writeFileSync(path.join(root, "nimbus.json"), JSON.stringify({ preview: { pr: 123 } })); + assert.equal(resolveUpgradeBaseline({ projectRoot: root, targetVersion: "0.13.1" }).source, "nimbus-json"); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ dependencies: { "@cloudflare/nimbus-docs": "https://pkg.pr.new/@cloudflare/nimbus-docs@123" } }), + ); + assert.equal(resolveUpgradeBaseline({ projectRoot: root, targetVersion: "0.13.1" }).source, "preview"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("malformed nimbus.json guidance names the file and recovery command", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-upgrades-malformed-")); + try { + fs.writeFileSync(path.join(root, "nimbus.json"), "{"); + for (const result of [ + resolveUpgradeBaseline({ projectRoot: root, targetVersion: "0.13.1" }), + resolveUpgradeBaseline({ projectRoot: root, fromVersion: "0.12.0", targetVersion: "0.13.1" }), + ]) { + assert.match(result.error ?? "", /Could not read nimbus\.json/); + assert.match(result.error ?? "", /nimbus-docs init --force/); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("installedNimbusVersion finds an installed project or workspace package", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-installed-version-")); + try { + const project = path.join(root, "packages", "docs"); + fs.mkdirSync(path.join(root, "node_modules", "@cloudflare", "nimbus-docs"), { recursive: true }); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync( + path.join(root, "node_modules", "@cloudflare", "nimbus-docs", "package.json"), + JSON.stringify({ version: "0.12.0" }), + ); + assert.equal(installedNimbusVersion(project), "0.12.0"); + assert.match( + resolveUpgradeBaseline({ projectRoot: project }).error ?? "", + new RegExp(`executing Nimbus CLI is ${runningNimbusVersion().replaceAll(".", "\\.")}`), + ); + fs.writeFileSync( + path.join(root, "node_modules", "@cloudflare", "nimbus-docs", "package.json"), + JSON.stringify({ version: "not-semver" }), + ); + assert.throws(() => installedNimbusVersion(project), /invalid version/); + assert.match(resolveUpgradeBaseline({ projectRoot: project }).error ?? "", /installed Nimbus package metadata/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/nimbus-starter-source/AGENT.md b/packages/nimbus-starter-source/AGENT.md index 2e0df159..f723e4f6 100644 --- a/packages/nimbus-starter-source/AGENT.md +++ b/packages/nimbus-starter-source/AGENT.md @@ -1,11 +1,14 @@ # This Nimbus docs site +> `CLAUDE.md` delegates here. Keep project instructions canonical in this file. + Astro-based docs. The `nimbus-docs` package handles content schemas, sidebar/TOC, MDX→markdown, build hooks, and the `nimbus` CLI. Everything in `src/` is yours to edit. ## File layout ``` astro.config.ts # imports nimbus + defineNimbusConfig +nimbus.json # records the last reviewed Nimbus package version src/ ├── components.ts # MDX globals registry — every component used in .mdx must be listed ├── components/ # AgentDirective, Header, Render + ui// @@ -63,11 +66,25 @@ Rules: | Custom page route | Add a file under `src/pages/`. | | Custom OG style | Edit `src/pages/og/_og-card-config.ts`. | | Check for updates | `pnpm exec nimbus-docs outdated` — starter files behind their tag + registry components behind. | +| Upgrade Nimbus | Update the package, then run `pnpm exec nimbus-docs migrate --dry-run --diff`. Review every change and required manual step before applying. | | Upgrade a starter file | `pnpm exec nimbus-docs diff ` to review, `diff --apply ` to pull a clean upstream change. | | Upgrade a registry component | `pnpm exec nimbus-docs add --overwrite`, then review with `git diff`. | List installable items: `pnpm exec nimbus-docs list`. +## Upgrading Nimbus + +Keep `nimbus.json` committed. Its `lastReviewedNimbusVersion` is the baseline Nimbus uses to select the versioned reviews crossed by a package upgrade; state-detected migrations come from the current project files. It is not a package pin and should not be edited by hand. + +1. Update `@cloudflare/nimbus-docs` with the project's package manager. +2. Preview the complete plan with `pnpm exec nimbus-docs migrate --dry-run --diff`. If no baseline exists yet, add `--from `. +3. Review every versioned entry and resolve each blocked/manual item. +4. Apply safe edits only with explicit consent: `pnpm exec nimbus-docs migrate --yes`. Review the resulting diff, then rerun the preview. +5. When no migration remains, run `pnpm exec nimbus-docs migrate --yes` again to record the completed review in `nimbus.json`. +6. Run the project's typecheck and production build, then run `pnpm exec nimbus-docs check` again for post-build coverage. + +Except for task-printing mode (`--print`), `migrate` exits nonzero while work or review remains; that is a pending-upgrade signal, not necessarily a command failure. Never skip versions by changing `nimbus.json` directly. + ## Audit this site Start with `pnpm exec nimbus-docs check --json`. It runs the environment, structural, authoring, and type checks build-free — config validity, `site` placeholder, route collisions, MDX component resolution, the lint rules, and a `tsc` type-check — and returns three top-level signals plus per-scope detail: diff --git a/packages/nimbus-starter-source/CLAUDE.md b/packages/nimbus-starter-source/CLAUDE.md new file mode 100644 index 00000000..a16fea43 --- /dev/null +++ b/packages/nimbus-starter-source/CLAUDE.md @@ -0,0 +1,3 @@ +# This Nimbus docs site + +Read and follow [AGENT.md](./AGENT.md). It is the canonical project guidance for editing, auditing, upgrading, and validating this Nimbus site. diff --git a/packages/nimbus-starter-source/nimbus.json b/packages/nimbus-starter-source/nimbus.json new file mode 100644 index 00000000..230eb550 --- /dev/null +++ b/packages/nimbus-starter-source/nimbus.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://nimbus-docs.com/schema/nimbus.json", + "lastReviewedNimbusVersion": "0.13.1" +} diff --git a/scripts/audit-published-prod.mjs b/scripts/audit-published-prod.mjs deleted file mode 100644 index 780abb68..00000000 --- a/scripts/audit-published-prod.mjs +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from "node:child_process"; - -const BLOCKING_SEVERITIES = new Set(["high", "critical"]); -const AUDIT_TIMEOUT_MS = 60_000; -const PUBLISHED_IMPORTERS = new Set([ - "packages/nimbus-docs", - "packages/create-nimbus-docs", -]); - -const ALLOWLIST = new Map([]); - -const result = spawnSync( - "pnpm", - [ - "--config.registry=https://registry.npmjs.org/", - "--config.@cloudflare:registry=https://registry.npmjs.org/", - "audit", - "--prod", - "--json", - ], - { encoding: "utf8", timeout: AUDIT_TIMEOUT_MS }, -); - -if (result.error) { - if (result.error.code === "ETIMEDOUT") { - console.error( - `pnpm audit timed out after ${AUDIT_TIMEOUT_MS / 1000}s. The npm audit service may be unavailable.`, - ); - process.exit(1); - } - console.error(`Failed to run pnpm audit: ${result.error.message}`); - process.exit(1); -} - -if (!result.stdout.trim()) { - console.error(result.stderr.trim() || "pnpm audit produced no JSON output."); - process.exit(1); -} - -let report; -try { - report = JSON.parse(result.stdout); -} catch (err) { - console.error(`Could not parse pnpm audit JSON: ${err.message}`); - process.exit(1); -} - -if (isPlainObject(report?.error)) { - console.error( - `pnpm audit failed: ${String(report.error.summary ?? report.error.message ?? "unknown registry error")}`, - ); - process.exit(1); -} - -const advisories = report?.advisories; -if (!isPlainObject(advisories)) { - if (isPlainObject(report?.vulnerabilities)) { - console.error( - "Unsupported pnpm audit JSON: npm-audit v2 vulnerabilities schema returned. Update scripts/audit-published-prod.mjs before trusting this gate.", - ); - } else { - console.error( - `Unexpected pnpm audit JSON: missing advisories object and vulnerabilities object (keys: ${Object.keys(report ?? {}).join(", ") || "none"}).`, - ); - } - process.exit(1); -} - -const scoped = []; - -for (const advisory of Object.values(advisories)) { - if (!isPlainObject(advisory)) continue; - if (!BLOCKING_SEVERITIES.has(String(advisory.severity))) continue; - - const ghsa = String(advisory.github_advisory_id ?? advisory.id ?? "unknown"); - if (!Array.isArray(advisory.findings) || advisory.findings.length === 0) { - failUnexpectedAdvisoryShape(ghsa, "missing non-empty findings array"); - } - - for (const finding of advisory.findings) { - if (!isPlainObject(finding)) { - failUnexpectedAdvisoryShape(ghsa, "finding is not an object"); - } - if (!Array.isArray(finding.paths) || finding.paths.length === 0) { - failUnexpectedAdvisoryShape(ghsa, "missing non-empty finding.paths array"); - } - - for (const path of finding.paths) { - if (typeof path !== "string") { - failUnexpectedAdvisoryShape(ghsa, "finding path is not a string"); - } - const importer = firstPathSegment(path); - if (!PUBLISHED_IMPORTERS.has(importer)) continue; - - const entry = { - ghsa, - importer, - module: String(advisory.module_name ?? "unknown"), - severity: String(advisory.severity), - title: String(advisory.title ?? "Untitled advisory"), - path, - }; - scoped.push(entry); - } - } -} - -const usedAllowlistKeys = new Set(); -const unallowlisted = scoped.filter((entry) => { - const entryKey = key(entry.ghsa, entry.importer, entry.module); - if (!ALLOWLIST.has(entryKey)) return true; - usedAllowlistKeys.add(entryKey); - return false; -}); -const staleAllowlistKeys = [...ALLOWLIST.keys()].filter( - (entryKey) => !usedAllowlistKeys.has(entryKey), -); - -if (unallowlisted.length > 0) { - console.error("Published package audit failed. New high/critical findings:"); - for (const entry of unallowlisted) printEntry(entry); - process.exit(1); -} - -if (staleAllowlistKeys.length > 0) { - console.error("Published package audit failed. Stale allowlist entries:"); - for (const entryKey of staleAllowlistKeys) console.error(`- ${entryKey}`); - process.exit(1); -} - -if (scoped.length === 0) { - console.log("Published package audit passed: no high/critical prod findings."); -} else { - console.log( - `Published package audit passed: ${scoped.length} high/critical prod finding(s) are allowlisted.`, - ); - for (const entry of scoped) { - const reason = ALLOWLIST.get(key(entry.ghsa, entry.importer, entry.module)); - console.log(`- ${entry.ghsa} ${entry.path}: ${reason}`); - } -} - -function key(ghsa, importer, moduleName) { - return `${ghsa}|${importer}|${moduleName}`; -} - -function isPlainObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function failUnexpectedAdvisoryShape(ghsa, reason) { - console.error(`Unexpected pnpm audit JSON for ${ghsa}: ${reason}.`); - process.exit(1); -} - -function firstPathSegment(path) { - return path.split(" > ")[0] ?? ""; -} - -function printEntry(entry) { - console.error(`- [${entry.severity}] ${entry.ghsa} ${entry.importer} > ${entry.module}`); - console.error(` ${entry.title}`); - console.error(` ${entry.path}`); -} diff --git a/scripts/release.mjs b/scripts/release.mjs index 252b0334..143fdf19 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -312,7 +312,12 @@ const { cmd, flags } = parse(process.argv.slice(2)); const main = async () => { if (cmd === "publish") return publish(flags); - if (cmd === "publish-only") return publishOnly({ pushTags: true }); + if (cmd === "publish-only") { + if (flags.dryRun || flags.haltAfter !== undefined) { + die("publish-only does not support --dry-run or --halt-after."); + } + return publishOnly({ pushTags: true }); + } die(`unknown command "${cmd ?? ""}". Use "publish" or "publish-only".`); }; diff --git a/scripts/sync-reviewed-baselines.mjs b/scripts/sync-reviewed-baselines.mjs new file mode 100644 index 00000000..64163333 --- /dev/null +++ b/scripts/sync-reviewed-baselines.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const RECORDS = [ + "apps/www/nimbus.json", + "packages/nimbus-starter-source/nimbus.json", +]; + +export function syncReviewedBaselines(root = ROOT) { + const packageFile = path.join(root, "packages/nimbus-docs/package.json"); + const version = JSON.parse(fs.readFileSync(packageFile, "utf8")).version; + if (typeof version !== "string") throw new Error("Nimbus package version is missing."); + + for (const relative of RECORDS) { + const file = path.join(root, relative); + const record = JSON.parse(fs.readFileSync(file, "utf8")); + fs.writeFileSync( + file, + `${JSON.stringify({ ...record, lastReviewedNimbusVersion: version }, null, 2)}\n`, + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + syncReviewedBaselines(); + console.log("[upgrade-baselines] synchronized"); +} diff --git a/scripts/sync-reviewed-baselines.test.mjs b/scripts/sync-reviewed-baselines.test.mjs new file mode 100644 index 00000000..296aaedc --- /dev/null +++ b/scripts/sync-reviewed-baselines.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { syncReviewedBaselines } from "./sync-reviewed-baselines.mjs"; + +test("versioning advances first-party reviewed baselines", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-reviewed-baselines-")); + try { + fs.mkdirSync(path.join(root, "packages/nimbus-docs"), { recursive: true }); + fs.mkdirSync(path.join(root, "packages/nimbus-starter-source"), { recursive: true }); + fs.mkdirSync(path.join(root, "apps/www"), { recursive: true }); + fs.writeFileSync(path.join(root, "packages/nimbus-docs/package.json"), JSON.stringify({ version: "0.14.0" })); + fs.writeFileSync(path.join(root, "packages/nimbus-starter-source/nimbus.json"), JSON.stringify({ custom: true, lastReviewedNimbusVersion: "0.13.1" })); + fs.writeFileSync(path.join(root, "apps/www/nimbus.json"), JSON.stringify({ lastReviewedNimbusVersion: "0.13.1" })); + + syncReviewedBaselines(root); + + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, "packages/nimbus-starter-source/nimbus.json"), "utf8")), { + custom: true, + lastReviewedNimbusVersion: "0.14.0", + }); + assert.equal( + JSON.parse(fs.readFileSync(path.join(root, "apps/www/nimbus.json"), "utf8")).lastReviewedNimbusVersion, + "0.14.0", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/upgrade-manifest.mjs b/scripts/upgrade-manifest.mjs new file mode 100644 index 00000000..57efbdae --- /dev/null +++ b/scripts/upgrade-manifest.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { inc, lt, major, valid } from "semver"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const MANIFEST = path.join( + ROOT, + "packages/nimbus-docs/src/_internal/upgrade-manifest.json", +); +const MODES = new Set(["automatic", "detectable-manual", "review-required"]); + +export function validateUpgradeManifest(value) { + if ( + !value || + value.schemaVersion !== 1 || + !valid(value.oldestSupportedVersion) || + !Array.isArray(value.entries) + ) { + throw new Error( + "Upgrade manifest must use schemaVersion 1, a valid oldestSupportedVersion, and entries[].", + ); + } + const ids = new Set(); + for (const entry of value.entries) { + if (!entry || typeof entry !== "object") + throw new Error("Every upgrade entry must be an object."); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id ?? "")) + throw new Error(`Invalid upgrade entry ID: ${entry.id}.`); + if (ids.has(entry.id)) + throw new Error(`Duplicate upgrade entry ID: ${entry.id}.`); + ids.add(entry.id); + if (!valid(entry.introducedIn)) + throw new Error(`${entry.id} has an invalid introducedIn version.`); + if (lt(entry.introducedIn, value.oldestSupportedVersion)) { + throw new Error(`${entry.id} predates oldestSupportedVersion.`); + } + if (!MODES.has(entry.mode)) + throw new Error(`${entry.id} has an invalid mode.`); + if (entry.mode === "automatic" && !entry.migrationId) + throw new Error(`${entry.id} automatic entries require migrationId.`); + for (const field of ["summary", "affected"]) { + if (typeof entry[field] !== "string" || !entry[field].trim()) + throw new Error(`${entry.id} requires ${field}.`); + } + for (const field of ["instructions", "verify"]) { + if ( + !Array.isArray(entry[field]) || + entry[field].length === 0 || + entry[field].some((item) => typeof item !== "string" || !item.trim()) + ) { + throw new Error(`${entry.id} requires non-empty ${field}[].`); + } + } + if ( + entry.changeset !== undefined && + !/^[a-z0-9-]+$/.test(entry.changeset) + ) { + throw new Error(`${entry.id} has an invalid changeset ID.`); + } + } + return value; +} + +export function validateBreakingDeclaration({ + breaking, + previousEntries, + currentEntries, + changesets, + currentVersion, +}) { + if (previousEntries === null) return; + const previous = new Set(previousEntries.map((entry) => entry.id)); + const added = currentEntries.filter((entry) => !previous.has(entry.id)); + if (breaking && added.length === 0) + throw new Error( + "A PR labeled breaking-change must add at least one upgrade manifest entry.", + ); + for (const entry of added) { + if (!entry.changeset) + throw new Error(`${entry.id} must reference its pending changeset.`); + const body = changesets.get(entry.changeset); + if (!body) + throw new Error( + `${entry.id} references missing changeset .changeset/${entry.changeset}.md.`, + ); + const bump = changesetBump(body, "@cloudflare/nimbus-docs"); + const requiredBump = currentVersion && major(currentVersion) > 0 ? "major" : "minor"; + if (bump !== requiredBump) { + throw new Error( + `${entry.id}'s changeset must give @cloudflare/nimbus-docs a breaking-compatible bump (${requiredBump}).`, + ); + } + if (currentVersion) { + const introducedIn = inc(currentVersion, bump); + if (entry.introducedIn !== introducedIn) { + throw new Error( + `${entry.id} must use introducedIn ${introducedIn}, matching its changeset release.`, + ); + } + } + } +} + +export function validateManifestContinuity(previousManifest, currentManifest) { + if (previousManifest.oldestSupportedVersion !== currentManifest.oldestSupportedVersion) { + throw new Error("Upgrade manifest oldestSupportedVersion cannot be changed."); + } + const current = new Map(currentManifest.entries.map((entry) => [entry.id, entry])); + for (const previous of previousManifest.entries) { + const entry = current.get(previous.id); + if (!entry) + throw new Error( + `Upgrade manifest entry ${previous.id} cannot be removed.`, + ); + if (!isDeepStrictEqual(entry, previous)) + throw new Error( + `Upgrade manifest entry ${previous.id} cannot be changed.`, + ); + } +} + +function changesetBump(body, packageName) { + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(body)?.[1]; + if (!frontmatter) return null; + for (const line of frontmatter.split(/\r?\n/)) { + const match = /^\s*["']?([^"':]+)["']?\s*:\s*(patch|minor|major)\s*$/.exec( + line, + ); + if (match?.[1]?.trim() === packageName) return match[2]; + } + return null; +} + +function loadManifest(file = MANIFEST) { + return validateUpgradeManifest(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +export function loadPreviousManifest(base) { + const relative = path.relative(ROOT, MANIFEST).split(path.sep).join("/"); + const refs = [`origin/${base}`, base]; + let commit = null; + for (const ref of refs) { + try { + commit = execFileSync( + "git", + ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + break; + } catch { + } + } + if (!commit) throw new Error(`Could not resolve base ref ${base}.`); + try { + execFileSync("git", ["cat-file", "-e", `${commit}:${relative}`], { + cwd: ROOT, + stdio: "ignore", + }); + } catch { + return null; + } + const raw = execFileSync("git", ["show", `${commit}:${relative}`], { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + return validateUpgradeManifest(JSON.parse(raw)); +} + +function pendingChangesets() { + const values = new Map(); + for (const file of fs.readdirSync(path.join(ROOT, ".changeset"))) { + if (!file.endsWith(".md") || file.toLowerCase() === "readme.md") continue; + values.set( + file.slice(0, -3), + fs.readFileSync(path.join(ROOT, ".changeset", file), "utf8"), + ); + } + return values; +} + +export function runUpgradeManifestCheck({ + breaking = false, + baseRef = "main", +} = {}) { + const manifest = loadManifest(); + const previous = loadPreviousManifest(baseRef); + if (previous) validateManifestContinuity(previous, manifest); + validateBreakingDeclaration({ + breaking, + previousEntries: previous?.entries ?? null, + currentEntries: manifest.entries, + changesets: pendingChangesets(), + currentVersion: JSON.parse( + fs.readFileSync( + path.join(ROOT, "packages/nimbus-docs/package.json"), + "utf8", + ), + ).version, + }); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + runUpgradeManifestCheck({ + breaking: + process.env.BREAKING_CHANGE === "1" || + process.env.BREAKING_CHANGE === "true", + baseRef: process.env.BASE_REF ?? process.env.GITHUB_BASE_REF ?? "main", + }); + console.log("[upgrade-manifest] valid"); + } catch (error) { + console.error( + `[upgrade-manifest] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/scripts/upgrade-manifest.test.mjs b/scripts/upgrade-manifest.test.mjs new file mode 100644 index 00000000..8d8946a6 --- /dev/null +++ b/scripts/upgrade-manifest.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + loadPreviousManifest, + validateBreakingDeclaration, + validateManifestContinuity, + validateUpgradeManifest, +} from "./upgrade-manifest.mjs"; + +function entry(id, extra = {}) { + return { + id, + introducedIn: "0.14.0", + mode: "review-required", + summary: `Review ${id}.`, + affected: "Affected projects.", + instructions: ["Make the change."], + verify: ["Build the project."], + ...extra, + }; +} + +test("manifest validation rejects duplicates and incomplete automatic entries", () => { + assert.throws( + () => + validateUpgradeManifest({ + schemaVersion: 1, + oldestSupportedVersion: "0.11.0", + entries: [entry("same"), entry("same")], + }), + /Duplicate/, + ); + assert.throws( + () => + validateUpgradeManifest({ + schemaVersion: 1, + oldestSupportedVersion: "0.11.0", + entries: [entry("automatic", { mode: "automatic" })], + }), + /migrationId/, + ); + assert.throws( + () => + validateUpgradeManifest({ + schemaVersion: 1, + oldestSupportedVersion: "0.15.0", + entries: [entry("old")], + }), + /predates/, + ); +}); + +test("manifest continuity preserves shipped entries", () => { + const manifest = (entries, oldestSupportedVersion = "0.11.0") => ({ + schemaVersion: 1, + oldestSupportedVersion, + entries, + }); + assert.throws( + () => validateManifestContinuity(manifest([entry("existing")]), manifest([])), + /cannot be removed/, + ); + assert.throws( + () => + validateManifestContinuity( + manifest([entry("existing")]), + manifest([entry("existing", { introducedIn: "0.15.0" })]), + ), + /cannot be changed/, + ); + assert.throws( + () => + validateManifestContinuity( + manifest([entry("existing")]), + manifest([entry("existing", { summary: "Rewritten." })]), + ), + /cannot be changed/, + ); + assert.doesNotThrow(() => + validateManifestContinuity( + manifest([entry("existing")]), + manifest([entry("existing"), entry("new")]), + ), + ); + assert.throws( + () => validateManifestContinuity(manifest([]), manifest([], "0.12.0")), + /oldestSupportedVersion/, + ); +}); + +test("breaking declarations require a new entry linked to a Nimbus changeset", () => { + const current = entry("new-break", { changeset: "breaking-change" }); + assert.doesNotThrow(() => + validateBreakingDeclaration({ + breaking: true, + previousEntries: null, + currentEntries: [entry("historical")], + changesets: new Map(), + }), + ); + assert.throws( + () => + validateBreakingDeclaration({ + breaking: true, + previousEntries: [], + currentEntries: [], + changesets: new Map(), + }), + /add at least one/, + ); + assert.throws( + () => + validateBreakingDeclaration({ + breaking: true, + previousEntries: [], + currentEntries: [current], + changesets: new Map(), + }), + /missing changeset/, + ); + assert.throws( + () => + validateBreakingDeclaration({ + breaking: false, + previousEntries: [], + currentEntries: [current], + changesets: new Map(), + }), + /missing changeset/, + ); + assert.doesNotThrow(() => + validateBreakingDeclaration({ + breaking: true, + previousEntries: [], + currentEntries: [current], + changesets: new Map([ + ["breaking-change", '---\n"@cloudflare/nimbus-docs": minor\n---\n'], + ]), + }), + ); + assert.throws( + () => + validateBreakingDeclaration({ + breaking: true, + previousEntries: [entry("existing")], + currentEntries: [entry("existing"), current], + changesets: new Map([ + [ + "breaking-change", + '---\n"another-package": minor\n---\n\nMention @cloudflare/nimbus-docs.', + ], + ]), + currentVersion: "0.13.1", + }), + /breaking-compatible bump/, + ); + assert.throws( + () => + validateBreakingDeclaration({ + breaking: true, + previousEntries: [entry("existing")], + currentEntries: [ + entry("existing"), + entry("new-break", { + changeset: "breaking-change", + introducedIn: "0.15.0", + }), + ], + changesets: new Map([ + ["breaking-change", '---\n"@cloudflare/nimbus-docs": minor\n---\n'], + ]), + currentVersion: "0.13.1", + }), + /introducedIn 0.14.0/, + ); +}); + +test("base manifest loading fails closed for an unresolved ref", () => { + assert.throws( + () => loadPreviousManifest("definitely-not-a-real-base-ref"), + /Could not resolve base ref/, + ); +});