diff --git a/.github/workflows/faq.yml b/.github/workflows/faq.yml index 7f300d32..37f0248e 100644 --- a/.github/workflows/faq.yml +++ b/.github/workflows/faq.yml @@ -33,6 +33,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + fetch-depth: 0 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml new file mode 100644 index 00000000..de95c4cb --- /dev/null +++ b/.github/workflows/manual.yml @@ -0,0 +1,99 @@ +name: Public manual + +on: + pull_request: + paths: + - 'apps/manual/**' + - 'apps/website/**' + - 'docs/manual/**' + - 'tools/manual-*.mjs' + - 'tools/public-build.mjs' + - 'tools/website-*.mjs' + - 'package*.json' + - '.github/workflows/manual.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: manual-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + static-reading: + name: Two hosts / Chromium + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + diagnostic: ${{ steps.diagnostic.outputs.summary }} + env: + DIFFDEVIL_EDIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: '22' + cache: npm + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm --prefix apps/manual run toolchain + - run: npm run website:build + - run: npm --prefix apps/manual test + - run: npm --prefix apps/manual run test:projection + - run: npm --prefix apps/manual run check + - run: npx --no-install playwright install --with-deps chromium + - run: npm --prefix apps/manual run qa + - name: Qualify the complete affected website journeys + run: npm run qa:website + - name: Preserve the admitted FAQ journey + run: node apps/website/qa/faq.mjs + - name: Summarize browser qualification + id: diagnostic + if: always() + run: | + node --input-type=module <<'NODE' + import { readFileSync, existsSync, appendFileSync } from 'node:fs'; + const manualPath = 'artifacts/manual/qa/result.json'; + const faqPath = 'artifacts/website/faq-qa/result.json'; + const manual = existsSync(manualPath) ? JSON.parse(readFileSync(manualPath, 'utf8')) : null; + const faq = existsSync(faqPath) ? JSON.parse(readFileSync(faqPath, 'utf8')) : null; + const clean = value => String(value).replace(/\u001b\[[0-9;]*m/g, '').replace(/\s+/g, ' '); + const summary = [ + !manual ? 'Manual: no browser result' : manual.ok ? 'Manual: passed' : 'Manual: ' + manual.failures.map(failure => clean(failure.name + ': ' + failure.error)).join(' | '), + !faq ? 'FAQ: no browser result' : faq.passed ? 'FAQ: passed' : 'FAQ: ' + (faq.failures?.length ? faq.failures.map(clean).join(' | ') : clean(faq.error ?? 'failed')), + ].join(' | '); + appendFileSync(process.env.GITHUB_OUTPUT, 'summary=' + summary.slice(0, 240) + '\n'); + NODE + - name: Qualification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: public-manual-${{ github.event.pull_request.head.sha || github.sha }} + retention-days: 10 + if-no-files-found: warn + path: | + artifacts/manual/ + artifacts/website/dist/ + artifacts/website/qa/ + artifacts/website/faq-qa/ + artifacts/public-search/records.json + artifacts/manual-toolchain/source-receipt.json + artifacts/manual-toolchain/consumer-receipt.json + apps/manual/package-lock.json + + qualification-result: + needs: static-reading + if: always() + name: ${{ needs.static-reading.outputs.diagnostic || 'Manual qualification did not return a result' }} + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + steps: + - name: Preserve the qualification outcome + env: + RESULT: ${{ needs.static-reading.result }} + run: test "$RESULT" = success diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 0fb5e32f..9795f4f2 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -1,13 +1,13 @@ name: Verify on: push: - branches: [main] + branches: [main, docs/public-manual] pull_request: workflow_dispatch: permissions: contents: read concurrency: - group: verify-${{ github.workflow }}-${{ github.ref }} + group: verify-${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: verify: @@ -29,7 +29,9 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6, reviewed metadata 2026-09-15 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + fetch-depth: 0 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6, reviewed metadata 2026-09-15 with: node-version: ${{ matrix.node }} diff --git a/.gitignore b/.gitignore index 9662487f..a6d7dc90 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ coverage/ # Repository-local artifacts and editor noise # Leitsatz: product-owned generated output /artifacts/ -# Generated website docs collection (tools/website-docs.mjs) and Astro type output +# Retired generated apex collection: remove stale local output on the next projection. +# Current FAQ/source provenance and Astro type output are also generated. apps/website/src/content/docs/ apps/website/src/content/sidebar.json apps/website/src/generated/faq.md @@ -35,3 +36,15 @@ Thumbs.db # Deliberately shipped native-ESM Action closure, not the developer install. !actions/runtime/ !actions/runtime/** + +# Explicit-source manual projection and selected public assets +apps/manual/src/content/docs/ +apps/manual/src/generated/ +apps/manual/public/ +apps/manual/.astro/ +apps/website/src/generated/source-targets.json + +# The source-only manual toolchain retains its resolved lock with qualification evidence. +apps/manual/package-lock.json + +apps/website/src/generated/source-provenance.json diff --git a/AGENTS.md b/AGENTS.md index 6c3620fd..5e9c2c41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ main: required pre-integration evidence: Verify on the exact candidate head required approval/review: none resolved conversations: no - automatic CI: Verify on main push and pull request (Linux Node 22/24, Windows Node 24) + automatic CI: Verify on main push and pull request (Linux and Windows, Node 22/24) automatic retained branch effects: none other pre-update evidence: none ``` @@ -52,11 +52,13 @@ node dist/lib/cli/main.js analyze --diff-file docs/examples/diffs/review.diff -- and checks exact generated Action parity. When source or packaging affects the Actions, run `npm run build:actions`, inspect the generated diff, and verify again. Run `npm run test:conformance`, `npm run test:package`, and `npm run test:actions` -for the separately named consumer boundaries. The public website builds with -`npm run website:build` and is qualified in a browser with `npm run qa:website`; -`verify` includes its build. No website command deploys, previews remotely, or -exposes anything. See `docs/DEVELOPMENT.md` for what -each proves. Do not replace executable proof with fixed prose or test-count gates. +for the separately named consumer boundaries. `npm run website:build` builds both +public static hosts and their joined search index; `verify` includes that build. +`npm run qa:website` qualifies the existing website, while +`npm --prefix apps/manual run qa` qualifies the two-host manual/FAQ interfaces. +No website command deploys, previews remotely, or exposes anything. See +`docs/DEVELOPMENT.md` and `apps/manual/README.md` for what each command proves. +Do not replace executable proof with fixed prose or test-count gates. ## Preserve the product @@ -76,13 +78,15 @@ not real credentials or live repository mutation. ## Generated and public boundaries Edit `src/diffdevil/`, not the generated `actions/runtime/` implementation or `action.yml` -files. The manual on the website is generated from repository Markdown selected in -`apps/website/docs-manifest.mjs`; edit the Markdown, never the ignored generated -collection. The Agent Skill and setup instructions are repository-owned product -content served by the site, not website copy. Commit reviewed Action distribution output because consumers do not install -dependencies. Root `dist/`, `node_modules/`, and `artifacts/` are generated or local; -do not force-add them. Never commit credentials, private raw conversations, -personal instructions, or local transfer archives. +files. The new public manual is generated from `docs/manual/` through the explicit +`apps/manual/manifest.mjs`. The apex no longer projects a competing manual. Its +finite legacy inventory is `apps/website/docs-manifest.mjs`; selected transfers and +captured fragments stay in the manual manifest and authoring state. The Agent Skill +and setup instructions are repository-owned product content served by the site, +not website copy. Commit reviewed Action distribution output because +consumers do not install dependencies. Root `dist/`, `node_modules/`, and `artifacts/` +are generated or local; do not force-add them. Never commit credentials, private raw +conversations, personal instructions, or local transfer archives. Inspect Git status and the exact staged diff before committing. Preserve unrelated work and attributable history. Public tags, releases, package publication, live diff --git a/README.md b/README.md index 3eb742a1..f9b63519 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,61 @@ # diffdevil [![npm version](https://img.shields.io/npm/v/%40wolfsblvt%2Fdiffdevil?logo=npm&color=CB3837)](https://www.npmjs.com/package/@wolfsblvt/diffdevil) -![Runtime](https://img.shields.io/badge/tested_runtimes-Node_22_%2B_24-339933?logo=nodedotjs) -![Language](https://img.shields.io/badge/detail-v1-536B92) +[![Verify](https://github.com/Wolfsblvt/diffdevil/actions/workflows/verify.yml/badge.svg?branch=main)](https://github.com/Wolfsblvt/diffdevil/actions/workflows/verify.yml) +![Runtime](https://img.shields.io/badge/Node-22%2B-339933?logo=nodedotjs) > **The devil is in the diff.** **Measure changes. Match rules. Act on the result.** -diffdevil is a product in development. - -A composable CLI, TypeScript library and GitHub Action for turning Git diffs into -queryable facts, configured rules, and inspectable label/comment effects. Raw churn counts a -three-line replacement as six lines. Replacement-aware changed lines count it -once, while keeping both sets of facts available. - -The public **v1.0.0 release** supports local Git, unified diffs, -saved reports, detail expressions, YAML/JSON policies, GitHub acquisition, and -explicit label/comment reconciliation. All four Actions ship executable JavaScript -and their dependencies, with no workflow installation step. Native and hosted -consumer matrices cover Node 22 and 24; a separate private live canary has exercised -label-definition creation/readback, managed assignment and no-op, trusted base policy -against a hostile PR-head copy, stale-plan refusal, one owned-comment update, and -split policy/effect credentials. A genuine external-fork event and live partial-write -failure remain unobserved. - -Reusable software is MIT; application and hosted-service software is selected for -AGPL-3.0-only. Original documentation prose is CC BY 4.0; runnable examples are -MIT; brand and visual assets remain reserved. The npm package, immutable -`v1.0.0` release, and maintained `@v1` Action alias are public. -A plan remains data, not an applied change. - -The first public read-only playground is live at -[`diffdevil-playground.wolfsblvt.workers.dev`](https://diffdevil-playground.wolfsblvt.workers.dev). -It analyzes public GitHub pull requests through the same engine and applies no -repository effects. GitHub's unauthenticated read limits remain an honest operating -boundary, so a temporarily exhausted provider route returns `429` rather than an -invented result. +A CLI, TypeScript library and set of GitHub Actions that turn diffs into useful +facts and deliberate automation. Count a three-line replacement as **three Changed**, +keep its **six lines of raw churn**, select the files that matter to your policy, +and use the result in a script, a check, a size band or an inspectable effect plan. +The same engine powers every surface. A diffdevil account is not required. + +**[Get started](#get-started)** · [Complete manual](docs/manual/README.md) · +[FAQ](docs/manual/faq.md) · [Releases](docs/manual/help/releases.md) + +GitHub and npm carry the released **v1.0.0 CLI, library and four Actions**. +diffdevil is a product in development. The browser extension and complete managed +service are not publicly released. The richer website, Examples and manual-host +experience is source-owned, not a claim that its domains are already serving it. +The [earlier public-PR measurement Playground](https://diffdevil-playground.wolfsblvt.workers.dev) +is a separate deployed surface, not that complete website experience. + +## Get started + +Use **Node.js 22 or later** and npm. Git is needed for Git-backed comparisons, not +for the packaged teaching patch below. Install the released package in your project: ```sh -node dist/lib/cli/main.js query --report docs/examples/reports/exact.json \ - --expr 'totals.lines.changed' --format value -# 178 +npm install --save-dev @wolfsblvt/diffdevil +npm exec -- diffdevil analyze --no-config --diff-file node_modules/@wolfsblvt/diffdevil/docs/examples/diffs/review.diff --format human ``` -**[Try the playground](https://diffdevil-playground.wolfsblvt.workers.dev)** · **[Label your PRs](docs/guides/auto-label-pull-requests.md)** · [Local scripts](docs/guides/local-automation.md) · [Policy recipes](docs/guides/policy-recipes.md) · [FAQ](docs/manual/faq.md) · [User manual](docs/automation.md) · [CLI reference](docs/integration/cli.md) · [Actions](docs/integration/github-actions.md) · [TypeScript API](docs/integration/typescript-api.md) +The complete four-file patch produces **10 Changed** and **16 raw churn**. The +report keeps additions, deletions and modifications distinct; `--no-config` makes +this first example independent of any policy already in your repository. + +Then use it on your own tracked worktree or staged changes: -## Label your PRs with one file +```sh +npm exec -- diffdevil analyze --format human +npm exec -- diffdevil analyze --staged --format human +npm exec -- diffdevil analyze --base main --head HEAD --format json +``` -Save this as `.github/workflows/diffdevil.yml` on your repository's default branch. -It creates missing size labels and maintains one on each matching PR. No config, -checkout, package install, personal token, or comment is required. +The last command uses the merge-base comparison. Inspect the report's source +revisions and inclusion rules before interpreting its total. The +[local-change guide](docs/manual/start/analyze-local-changes.md) explains each +comparison and the first repair for missing refs, configuration or an empty result. -The maintained `@v1` coordinate is public. Security-sensitive workflows can pin -the immutable `@v1.0.0` release instead. The complete -[auto-labeling quickstart](docs/guides/auto-label-pull-requests.md) explains setup, -permissions, label ranges, custom exclusions, and troubleshooting. +## Label pull requests with one file + +You need permission to add a workflow and allow it to write pull-request labels. +Save this complete workflow as `.github/workflows/diffdevil.yml` and make it +available through your repository's normal change process: ```yaml name: Pull-request size @@ -74,190 +74,60 @@ jobs: - uses: Wolfsblvt/diffdevil@v1 ``` -Keep this privileged job separate from PR-head checkout, builds, and tests. The -Action reads patches as data. Use `/analyze` for a read-only run instead. Existing -unrelated labels remain untouched; default ensure mode preserves existing label -colors and descriptions. An unavailable exact count is not silently replaced -with zero. - -The no-config route needs only Pull requests write. A trusted base or immutable -pinned policy file also needs Contents read. When those permissions belong to -different credentials, optional `policy-token` performs only policy/template reads; -`github-token` still owns pull-request acquisition and every label/comment effect. -Omitting `policy-token` preserves the ordinary single-credential route. - -## Get started - -Use Node.js 22 or later and npm. Local comparisons also require Git. Install the -released CLI and library: +Inspect a run and its PR: one managed `size/*` label should be applied or retained. +The default creates missing definitions, preserves unrelated labels and existing +label presentation, and posts **no comment**. An unresolved band can select +`size/Unknown`; it does not invent zero. -```sh -npm install --save-dev @wolfsblvt/diffdevil -npm exec -- diffdevil analyze \ - --diff-file node_modules/@wolfsblvt/diffdevil/docs/examples/diffs/review.diff \ - --format human -``` +This privileged job reads PR data without checking out or executing PR code. Keep +PR-head builds and tests separate. For read-only reports use +`Wolfsblvt/diffdevil/actions/analyze@v1`; the other explicit entries are +`/actions/apply` and `/actions/sync-labels`. All four ship their executable runtime, +so the workflow needs no npm install. Pin `@v1.0.0` or an exact release commit when +you need an immutable reference rather than the maintained `@v1` alias. -To develop from a source checkout instead: +[Labeling guide and recovery](docs/manual/start/label-pull-requests.md) · +[Actions operation](docs/manual/use/github-actions.md) · +[Exact inputs and outputs](docs/manual/reference/github-actions.md) -```sh -npm ci --ignore-scripts --no-audit --no-fund -npm run build -node dist/lib/cli/main.js analyze --diff-file docs/examples/diffs/review.diff --format human -``` - -The four-file teaching patch reports **10 changed lines** and **16 raw churn**. -Follow [local automation](docs/guides/local-automation.md) for exact values, path -queries, shell conditions, and reusable reports. Offline dependency restoration -is documented in [Development](docs/DEVELOPMENT.md#restore-and-build). -The source commands below use the built entry point; the installed package exposes -the same commands through `diffdevil`. +## Ask the question your script needs -Analyze tracked working-tree changes, compare two revisions, or read a patch: +Human and agent reports are reading interfaces. Use structured JSON, strict scalar +output or a process exit when another program consumes the result: ```sh -node dist/lib/cli/main.js analyze -node dist/lib/cli/main.js analyze --base main --head HEAD --format json -node dist/lib/cli/main.js analyze --diff-file docs/examples/diffs/review.diff --format json +npm exec -- diffdevil query --expr 'totals.lines.changed' --format value +npm exec -- diffdevil check --expr 'totals.lines.changed <= 250' +npm exec -- diffdevil query --files --metric modified --gt 20 --select path --format nul ``` -`--base main --head HEAD` uses the selected merge-base comparison. Reports retain -source revisions, inclusion reasons, raw facts, replacement-aware facts, and the -quality of the evidence. See [sources and output](docs/integration/cli.md). - -## Ask for the answer your script needs +`check` exits **0** for true, **1** for false, **2** for invalid input or operation +failure, and **3** for unresolved evidence. Exact output refuses evidence it cannot +represent honestly. NUL-separated paths avoid confusing a filename with a line of +shell output. [Complete Bash and PowerShell consumers](docs/manual/start/use-results-in-scripts.md) +show the full handling, including exit 3. -Use ordinary **detail** expressions for scalar formulas and simple conditions. -Use shortcuts where they remove collection and projection boilerplate. Every -alias remains available; both routes share one compiler and evaluator. +**Changed is a measurement, not a verdict.** Exact, bounded, unknown, unmeasurable +and incomplete file membership remain distinct. A bound may prove a threshold +without supplying an exact number. Neither Changed nor raw churn measures quality, +risk, importance or whether a PR should merge. +[Read the counting model](docs/manual/understand/changed-lines-and-churn.md) and +[evidence model](docs/manual/understand/evidence-and-uncertainty.md). -```sh -# A scalar, with no heading or diagnostic prose on stdout. -node dist/lib/cli/main.js query --expr 'totals.raw.added + totals.raw.deleted' --format value - -# A valid condition communicates its result through the exit code. -node dist/lib/cli/main.js check --expr 'totals.lines.changed > 100' - -# Collection shortcuts select included files and handle the quantifier/projection. -node dist/lib/cli/main.js check --files any --metric changed --gt 100 -node dist/lib/cli/main.js query --files --metric modified --gt 20 --select path --format lines -``` +## Grow into policy, not another tool -`check` exits **0** for true, **1** for false, **2** for invalid input or an -operation failure, and **3** for unresolved evidence. Strict scalar and path-list -output refuses an unresolved result rather than inventing a value. Diagnostics -go to stderr. Use `--format nul` for NUL-delimited path output, and `--expr-file` -or `--expr-stdin` for expressions that are awkward to quote. +Start with the built-in `size@1` preset. When you need more, ordinary YAML or JSON +policy gives you named scopes, typed parameters, metrics, queries, bands, rules, +labels and owned comments. The optional **detail** expression language uses the +same compiler and evaluator as the shortcuts. Expressions are never JavaScript. -[Complete formats and exit codes](docs/integration/cli.md) · [detail reference](docs/language.md) +The [policy guide](docs/manual/policy/README.md) builds one small example from a +preset to a deliberate rule. [Recipes](docs/manual/policy/recipes.md) provide +complete checked files, and [the reference](docs/manual/reference/README.md) owns +exact commands, APIs and language contracts. A plan describes desired effects; +it is not evidence that a label or comment was applied. -## Write policy once - -A discovered `.diffdevil.yml` or an explicit YAML/JSON file can define scopes, -parameters, metrics, saved queries, bands, rules, and desired effects. Formulas -use the same detail grammar as the CLI: - -```yaml -version: 1 -presets: [] -metrics: - rewritten: - formula: totals.lines.deleted + 2 * totals.lines.modified -queries: - rewritten: - expression: metrics.rewritten -rules: - broad-rewrite: - when: metrics.rewritten >= 500 - effects: - labels: - add: [review/broad-rewrite] - removeWhenFalse: true -``` - -The [complete weighted example](docs/examples/policies/weighted.yml) also defines a -typed threshold and its label definition. Query it or inspect its desired effects: - -```sh -node dist/lib/cli/main.js query --report docs/examples/reports/exact.json \ - --config docs/examples/policies/weighted.yml --name weighted --format value -# 248 - -node dist/lib/cli/main.js plan --report docs/examples/reports/exact.json \ - --config docs/examples/policies/weighted.yml --target-repo example/repository \ - --target-pr 42 --definitions ensure --format json -``` - -Policy validation rejects unknown names, type errors, metric cycles, invalid -bands, and conflicting assignments. YAML errors preserve original source -positions through quoting, folding, and aliases. Plans keep unresolved rules -held; they never interpret unknown as false or remove an unrelated label. - -[Policy and band manual](docs/language/policies-and-bands.md) · [Templates](docs/integration/templates.md) - -## Know what the numbers mean - -| Change | Raw additions | Raw deletions | Raw churn | Replacement-aware changed | -| --- | ---: | ---: | ---: | ---: | -| Replace three adjacent lines | 3 | 3 | 6 | 3 | -| Add five lines, delete two in the same edit block | 5 | 2 | 7 | 5 | -| Delete three here, add three elsewhere | 3 | 3 | 6 | 6 | - -The versioned `replacement-lines-v1` algorithm pairs additions and deletions -inside a contiguous edit block, never across unrelated locations. Added-only, -deleted-only, and modified facts remain individually queryable. - -**Exact, bounded, unknown, and unmeasurable are different evidence states.** A -bounded count can prove a threshold or band when every possible value agrees. -It cannot supply a fabricated exact scalar. Binary changes do not become zero -text lines. Neither line counts nor file counts measure importance, risk, -complexity, quality, or whether a change should merge. - -[Evidence and arithmetic](docs/language/types-and-measurements.md) · [Scopes and collections](docs/language/collections-and-scopes.md) - -## Use one of four Actions - -The shipped metadata selects **Node 24**. Given this exact trusted checkout at -`diffdevil/` in a workflow workspace, read-only analysis needs no npm install: - -```yaml -- uses: ./diffdevil/analyze - id: changes - with: - metric: changed - files: any - threshold: '100' - comparison: gt -``` - -`steps.changes.outputs.decision` is `true`, `false`, or `unknown`. This local path -requires the **complete trusted checkout**, not just its `analyze` subdirectory. -The [public source repository](https://github.com/Wolfsblvt/diffdevil) and -maintained `@v1` coordinate exist. Pin `@v1.0.0` or its exact release commit -when an immutable remote Action reference is required. - -| Entry | Behavior | -| --- | --- | -| Root | No-config size labeling: ensure missing definitions, reconcile only the managed size group, no comments. `mode: analyze` or `plan` selects a read-only route. | -| `/analyze` | Read-only facts, metrics, decisions and bands—even when a token is supplied. | -| `/apply` | Acquire fresh facts, revalidate supplied artifacts, then apply selected trusted policy. | -| `/sync-labels` | Verify definitions by default; explicit `operation: apply` ensures or synchronizes them. | - -Choosing root's default is choosing label automation. Write modes require suitable -workflow permissions; a token alone does not enable writes. Base or explicitly -pinned policy controls effects, never a PR's changed workspace config. Do not run -untrusted PR scripts in a privileged job. Partial writes remain an incomplete -result with a full readback journal, not a success-shaped guess. - -[Complete Action inputs and trust](docs/integration/github-actions.md) · -[Install-free distribution](docs/integration/action-distribution.md) - -## Use the same engine from TypeScript - -The package exports its root API and `/core`, `/language`, `/policy`, `/git`, and `/github` -subpaths with generated declarations. Parsing, binding, type checking, -interpretation, policy evaluation, and planning have separate responsibilities. -No user expression is evaluated as JavaScript. +The TypeScript library exposes the same engine through supported public imports: ```typescript import { readFile } from 'node:fs/promises'; @@ -267,53 +137,51 @@ const report = unwrap(analyzeDiff(await readFile('change.diff', 'utf8'))); console.log(report.totals.lines.changed); ``` -[API signatures and examples](docs/integration/typescript-api.md) · [Architecture](docs/ARCHITECTURE.md) +[Embedding and failure handling](docs/manual/use/typescript-library.md) · +[Public API reference](docs/manual/reference/typescript-api.md) -## Read GitHub changes and reconcile declared effects +## Choose where it works -Read a pull request without checking out or executing its code: - -```sh -node dist/lib/cli/main.js query --repo OWNER/REPO --pr 42 \ - --expr 'totals.lines.changed' --format value -``` +The open CLI, library and Actions are the product, not restricted entry tiers. +Other surfaces reuse their meaning and change who operates the work: -Private repositories require a suitable token in `GH_TOKEN` or `GITHUB_TOKEN`. -Read-only acquisition performs no label or comment mutation. The TypeScript -adapter exposes separate explicit application and label-definition operations. -It preserves unrelated metadata and checks current head/base revisions before -writes. Its provider behavior has deterministic fake-HTTP regression coverage and a -separate private live canary at the accepted source candidate. The canary proves -ordinary managed labels, trusted policy, freshness refusal, owned-comment update, -and split credentials; it does not claim a genuine external-fork event or live -partial-write recovery. +| Surface | What it adds | Start here | +| --- | --- | --- | +| CLI and scripts | Local comparisons, precise queries and explicit effects | [CLI](docs/manual/use/cli.md) | +| GitHub Actions | Repository-owned automation in reviewed workflows | [Actions](docs/manual/use/github-actions.md) | +| TypeScript library | The engine inside your own application | [Library](docs/manual/use/typescript-library.md) | +| Browser extension | Personal aggregate and per-file Changed inside GitHub, without repository writes | [Extension](docs/manual/use/browser-extension.md) | +| Playground and Examples | Read-only exploration of public PRs and frozen real-PR editions | [Playground](docs/manual/use/playground.md) | +| Managed App | Optional operated repository automation, administration and opt-in quantitative history | [Managed App](docs/manual/use/managed-app/README.md) | +| Coding-agent Skill | Persistent tool knowledge; executable access remains separate | [Coding agents](docs/manual/use/coding-agent.md) | -[GitHub API and trust boundaries](docs/integration/github-api.md) +Extension and managed-service guides describe the complete selected experience +beneath their availability notes. Self-hosting has its own +[Workers, Queue and D1 operator guide](docs/manual/use/managed-app/self-hosting.md). +Two hosts must not compete over the same managed labels or comment lifecycle. +[Shared workflows](docs/manual/use/shared-workflows/README.md) explains coexistence. -## Documentation and development +The selected public homes are [diffdevil.dev](https://diffdevil.dev/) and +[docs.diffdevil.dev](https://docs.diffdevil.dev/). Until their publication is verified, +the repository manual linked above remains the complete reading route. A configured +Store, App or download destination is not publication evidence. -Start with the [task guides](docs/README.md#start-with-a-task), then use the -[documentation map](docs/README.md) for complete language and integration -contracts. The [GitHub Action guide](docs/integration/github-actions.md) -documents the implemented interfaces and keeps local qualification separate from -hosted-runner and publication evidence. +## Documentation and contributing -The ordinary source verification command is `npm run verify`. Installed-package, -conformance, runtime, and live-provider evidence remain separate commands and -claims: [Development](docs/DEVELOPMENT.md) · [Qualification](docs/qualification.md) -· [v1.0.0 release notes](docs/releases/v1.0.0.md). +[The manual](docs/manual/README.md) owns user operation; the +[repository documentation map](docs/README.md) owns architecture, development, +contracts and dated evidence. [Development](docs/DEVELOPMENT.md) carries source +bootstrap and `npm run verify`, with package, Action and browser qualification kept +separate. [Release guidance](docs/manual/help/releases.md) distinguishes the package, +Action aliases, independently versioned Skill and source-only surfaces. -Suspected vulnerabilities can be [reported privately](SECURITY.md) through -GitHub's enabled repository reporting route. +Read [Security and data](docs/manual/help/security-and-data.md) before sharing a +report or choosing credentials. Report suspected vulnerabilities through the +[private security route](SECURITY.md), not a public issue. ## License -A Wolfsblvt Works product. Reusable engine, API, CLI and Action software is -[MIT](LICENSES/MIT.txt); website and hosted application/service software is selected -for [AGPL-3.0-only](LICENSES/AGPL-3.0-only.txt). Original documentation prose is -[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/legalcode), runnable -integration examples are MIT, and brand/visual assets are reserved. The current -checkout also ships the AGPL playground application source used by the live public -Worker. -[The component licence map](LICENSES/README.md) gives exact scope; no single -licence applies to the entire repository or npm tarball. +The [component licence map](LICENSE.md) assigns **MIT** to reusable engine, CLI, API, +Action software and mapped runnable examples, **AGPL-3.0-only** to application/service +software and **CC BY 4.0** to original documentation prose, while brand and visual +rights remain reserved as detailed in [LICENSES/README.md](LICENSES/README.md). diff --git a/apps/browser-extension/privacy.md b/apps/browser-extension/privacy.md index 4da1a503..a4dd5e50 100644 --- a/apps/browser-extension/privacy.md +++ b/apps/browser-extension/privacy.md @@ -35,3 +35,7 @@ The extension never applies a label automatically. Its native handoff opens and The extension requests storage, a content script on github.com to follow pull-request navigation, and host access to api.github.com for public read-only fallback. It does not request cookies, browsing history, tabs enumeration, all-site access, unlimited storage or clipboard permissions. The ordinary user-initiated clipboard capability can be declined by the browser. Support is available through Issues in the Wolfsblvt/diffdevil repository. Do not attach private source, credentials or unredacted configuration to a public issue. Before Store publication, the maintainer must verify this notice and the Store declarations against the exact packaged release. This notice is not a privacy certification or a guarantee of Store approval. + +## Related questions + +The [security and data FAQ](https://diffdevil.dev/faq/#code-data) explains how the extension differs from the CLI, Actions, public Playground and managed App. diff --git a/apps/manual/README.md b/apps/manual/README.md new file mode 100644 index 00000000..3c49a797 --- /dev/null +++ b/apps/manual/README.md @@ -0,0 +1,280 @@ +# Public manual source and rendering + +## Meaning + +The maintained Markdown is `docs/manual/`. This application renders only the +explicit pages in `manifest.mjs` at `https://docs.diffdevil.dev/`. The product site +and its admitted FAQ remain in `apps/website`; the FAQ is not a second manual page. +Application source here is AGPL-3.0-only. Documentation, examples and visual assets +keep their separate rights in the repository's licence map. + +## Build and inspect + +From the repository root, use `npm ci --ignore-scripts`, then `npm run website:build`. +This builds the engine and product site, then the website's build hook builds the +manual and one joined Pagefind index. Output is `artifacts/website/dist` and +`artifacts/manual/dist`. Neither command deploys anything. Ordinary `npm run verify` +uses this same two-host build through `check:website`. + +Manual-only commands belong to this application, not the reusable package's metadata: + +```sh +npm --prefix apps/manual run check +npm --prefix apps/manual test +npm --prefix apps/manual run test:projection +npm --prefix apps/manual run dev +npm --prefix apps/manual run qa +``` + +`check` builds the engine and checks the actual manual renderer. `test` checks the +source contracts and executable START/UNDERSTAND/USE/POLICY specimens; both require the built +engine. `test:projection` additionally requires the prepared toolchain +and built engine. `dev` serves the manual on loopback port 4322. `qa` starts from an +ordinary two-host production build and creates the isolated reading qualification +build described below. The root package and its Action distribution are unchanged +by these application-only commands. + +The manual consumes the exact accepted `Wolfsblvt/starlight-works` source commit +`22d4567006ec7a33d890fab2f3d3515332498a90` through that repository's own build and pack +commands. It is not an npm publication. `npm --prefix apps/manual run toolchain` +records source and consumer receipts under `artifacts/manual-toolchain/`. The package +supplies linked sidebar groups and the five GitHub alerts; this repository does not +copy their implementation. The manual's isolated dependency graph supplies its +supported Starlight 0.42.2 peer without rewriting the website's independent root +lockfile. A later root-toolchain update remains its own qualified source change. + +The source package is unpacked from its verified local tarball and installed with +`--install-links`, not linked into a sibling repository. Direct consumer versions +are pinned. The child lock is generated, retained with each qualification artifact, +and reused by subsequent preparations of that checkout. A clean preparation resolves +a new transitive graph and must earn its own evidence; it does not reproduce an +earlier graph merely because the source SHA is unchanged. The exact Git coordinate +and package receipt govern the unpublished package. `DIFFDEVIL_DOCS_OFFLINE=1` requires +a previously prepared source/dependency cache and refuses to download it. A clean +checkout needs one online preparation. `npm --prefix apps/manual run verify:offline` +sets that control while running ordinary root verification. + +## Author a chapter + +Keep one H1 matching its manifest title and ordinary GitHub-readable Markdown. +Relative links name maintained sources; the AST projection maps them to explicit +public routes or the finite source resolver. Code blocks are never rewritten. +Register public images in `assets.json`; the build copies only those assets. +Full working examples stay open. Secondary variants may use native disclosures. + +START, UNDERSTAND, USE, WRITE POLICY/REFERENCE and Managed App/Help have forty-one +authored chapters. The accepted FAQ stays separate. Authoring comments +are removed from the renderer, and readiness is private build state in +`authoring-state.json`. Complete the selected reader job, examples, expected results, +failure returns and source links before changing `scaffold` to `authored`. + +Capability availability is independent. A page marked `in-development` in the +manifest must carry exactly one top GitHub NOTE containing **In development**. +Removing that note requires rereading and correcting the governed page against +actual complete capability, not merely completing its prose. `capabilities.json` +records remaining source or public-availability dependencies without printing a +planning panel into every article. + +The sequence is Start + Understand, open surfaces + workflows, Policy + Reference, +then Managed App + Help, followed by final reconciliation and fresh review. All +waves continue the same draft branch. The root README is the quick manual; chapter depth remains in `docs/manual/`. + +## Routes, provenance and migration + +Public routes, aliases, sidebar parents and disclosure defaults are literal manifest +fields. They are never inferred from repository directories. Source links name the +exact candidate commit; Edit links name its actual authoring branch. The root is +What is diffdevil, with `/start/what-is-diffdevil/` as a 308 alias. + +`migration.mjs` represents every accepted moving and retained source family. +Existing guides stay current until all of a split's successors are authored. To +transfer a family, record its exact historical `fromRef`, SHA-256 and complete +`oldAnchors` (GitHub heading IDs plus `_top`) in `authoring-state.json`, select a +primary successor and map every old fragment to `{page, anchor}`. The renderer +reads the historical Git bytes to check that inventory and the new rendered +anchors to check the destinations. A retired source must actually be removed; +forwarding Markdown stubs refuse. Bounded operator residue or a release maintainer +map needs its separate explicit job. Incoming links must be updated in the same cut. + +A transfer also selects `redirects`, `sourceIds` and `search` together. Select each +legacy public route in `state.routes` as `{target: "page-key"}`. Unknown routes, +unready successors and partial family transfers refuse. No legacy route is +activated merely because a replacement file exists. Every emitted retired route +must have its explicit transfer, and selected source fragments must resolve. The +resolver preserves old source IDs and can direct an old fragment to a different +member of a split. Explicit filename aliases preserve selected pre-rename technical +source IDs; unknown casing, paths and URLs still refuse. + +The build emits a small Cloudflare Pages advanced-mode `_worker.js` handler in each +static output. It delegates ordinary requests to `env.ASSETS.fetch` and applies the +selected matching-route `www` to apex 308 and exact route aliases. This uses the +[native handler contract](https://developers.cloudflare.com/pages/functions/advanced-mode/), +because [domain-level redirects are not supported in `_redirects`](https://developers.cloudflare.com/pages/configuration/redirects/). +The accompanying `_redirects` files contain path rules only. Noindex HTML aliases +retain a usable link in plain static previews and preserve query/fragment when +JavaScript is enabled. No Markdown source forwarding files are written. + +Browser qualification executes the emitted handler against a local static-assets +binding. Redirect journeys use real loopback HTTP: canonical status and Location +are recorded before adapting only the transport origin, and the browser carries +fragments across the actual 308 responses. Host adoption and live HTTP readback +remain separate from this build. No hosting or DNS operation occurs. + +Wave 2 retires the old auto-label guide, local-automation guide and Playground +visitor guide. Their complete successor sets are authored; all old fragments, +source IDs, inbound links, three legacy public routes and search ownership move +together. Playground operator requirements remain in `apps/playground/README.md`. +Wave 3 additionally transfers the broad automation overview, recipes, CLI, Actions, +TypeScript, presets/shortcuts, templates, language overview, policies/bands and +versioning/interchange. Their 156 old fragments and ten old public routes move in +the same cut. Wave 4 completes the seven Managed App/Help pages and joins the five +detailed language sources to detail reference and symptom-led Troubleshooting. Their +complete old-fragment inventories, source IDs, inbound links and legacy routes move +together; all forty-one manual chapters are current/searchable. The release directory +keeps only its distinct maintainer map over dated notes. Final carrier-10 cross-surface +reconciliation and fresh review remain separate from this source-authoring result. +Parser architecture remains repository-owned: its old public route redirects to +the exact repository source, the finite source resolver remains available, and the +old article is no longer projected or indexed as public manual content. + +## Search, FAQ and generated inventories + +One Pagefind build joins selected product/legacy pages, current manual pages and +individual rendered FAQ questions. It is mirrored into both outputs, so either +origin uses a local copy without an unconfigured CORS dependency. Metadata carries +SITE, DOCS or FAQ independently of source paths. Scaffolds, legal/noindex routes, +the resolver, qualification pages and the whole FAQ page are not indexed. + +`related-questions.json` stores existing FAQ IDs only. Titles are read from the +admitted `docs/manual/faq.md`; answers, stable IDs, native disclosures and the +product-shell route remain owned by that source. Missing IDs refuse. The ordinary +FAQ browser qualification runs on the combined candidate as well. It executes the +emitted handlers for both canonical hosts, including legacy-to-manual links, rather +than assuming every FAQ destination is a page in the product host. The hosted +summary reports manual and FAQ outcomes separately. Its existing qualification +artifact includes a read-only Git bundle and commit/tree receipt under +`artifacts/manual/source/`, so source history remains recoverable even after a red +qualification; that transport is not publication or evidence of a passing run. + +The Policy/Reference inventories are committed inside paired edit boundaries: + +```markdown + +... mechanically emitted Markdown ... + +``` + +Edit authored framing outside those boundaries. Change canonical metadata, code, +schemas or catalogues for a mechanical fact, then run: + +```sh +npm --prefix apps/manual run generate +npm --prefix apps/manual run check:generated +``` + +The renderer also refuses exact-byte drift, missing closing boundaries, duplicate +islands and malformed markers. `generated-content.mjs` updates only paired bodies, +never prose, examples, recovery advice, conclusions or migration judgment. CLI +reference remains authored and checked against real help and option declarations. +Earlier waves retain their existing dynamic presenter and example markers. + +Thirteen committed blocks cover Action metadata, public exports and complete resolved +declarations, schema fields, the checked size preset, and detail operators, functions, +diagnostics, limits, environment, versions and shortcuts, plus App execution-stage +fallback codes/phases and checkout package/Action/Skill identities. Source metadata +does not prove provider publication. Public aliases, overloads, +generics, optional/readonly members and source links survive; external public +re-exports retain their dependency identity. Schema pointers and branch-local +requiredness remain explicit, with references, unions, definitions and boolean schemas +unflattened. Exact inputs and SHA-256, emitted bytes and SHA-256 are recorded in +`artifacts/manual/generated-islands.json` as reproducible build evidence, not a +second authored source registry. + +Presenter specimens use the shared engine and distinguish the small 10-Changed / +16-churn input from the 178-Changed presentation input. Complete excerpts are compared +with canonical files. `wave-3.test.mjs` executes every displayed read-only CLI invocation +and six independent policy chapters, proves false versus unresolved behavior, and +checks structurally valid but semantically invalid report/policy/plan counterparts. +`wave-4.test.mjs` checks the joined source-family/fragment cut, finite router IDs, +canonical read-only troubleshooting specimens, and unchanged prior-wave/FAQ bytes +except the explicit joined detail additions. App D1 and operator-helper suites remain +the real isolated operating-contract tests, not a documentation-only simulation. +The root test discovery and this application's `test` include both waves. Tests preserve +contracts and behavior rather than prose wording. + +The real-PR catalogue remains the one shared `docs/examples/catalogue` family. +Playground links carry separate `example=&variant=` parameters; the +combined `example--variant` key names an asset, not the public query contract. Controlled teaching patches +remain linked canonical assets, not fabricated gallery entries or obsolete synthetic +playground IDs. + +## Qualification and deliberate limits + +`npm --prefix apps/manual run qa` builds an isolated noindex reading fixture, then +runs Chromium against both actual static outputs under intercepted canonical HTTPS +origins and loopback HTTP for redirects. It checks routes/aliases, the finite +resolver, shared shell/theme, linked groups, five alerts, actual FAQ fragments, +search kinds, native no-JavaScript disclosures and narrow/keyboard behavior. +The normal production build never includes that fixture. Results and screenshots +name the exact candidate under `artifacts/manual/qa/`. The authored first-success +journey opens the retained PR lesson through its manual link, changes an exclusion, +exports separate valid report and plan JSON, inspects the saved-report CLI commands, +and rejects malformed public input. It checks actual authored pages at 1280 and +320 pixels. Set `PLAYWRIGHT_EXECUTABLE_PATH` to use an existing Chromium binary. + +The cross-host theme handoff is limited to owned site/manual HTML links. It transfers +only the existing preference and bounce side, then removes its query parameter +before first paint. Direct visits retain each origin's local preference. Same-page +fragment controls are not rewritten. No cookie, authentication or synchronization +service is implied. + +Automated DOM and browser evidence does not establish an actual screen-reader user +journey, live hosting, DNS, public App/Store availability or deployment acceptance. +None of those effects is performed by this foundation. + +## Source-family and final cross-surface cutover + +All 41 chapter sources and the separate canonical FAQ are authored. Eighteen old +reader guides/language sources are retired, without Markdown forwarding stubs; +`docs/releases/README.md` retains its separate release-map job. The exact family +and anchor dispositions remain in `migration.mjs` and `authoring-state.json`. + +The apex does not render old Starlight articles or another documentation homepage. +Its former selection manifest is now only the finite compatibility inventory: +manual successors receive 308s, while repository-only projections lead to exact +GitHub source. Retained specialist/raw sources have `projection` capture records +on their old routes, preserving their section destinations without redirecting +canonical technical source IDs away from their own GitHub files. Browser semantic +fragment handoff retains unrelated query parameters; no-JavaScript readers retain +native links to each mapped destination. + +The repository README supplies complete first-success CLI and Action paths; the +repository documentation map routes technical readers. Product adoption pages, +header/footer, FAQ, Examples, Playground, installation prompts and download links +use the explicit manual routes. No stale path replacement guesses a self-hosting +route or a nested extension privacy page. FAQ Source/Edit actions identify its +canonical Markdown and the exact build commit/contribution branch. + +The website's Skill handoff uses `/setup/skill.md`; the complete Skill tree and +five raw setup payloads keep their existing owners. Search has one current result +per selected URL: 41 manual chapters, five product pages and 20 FAQ questions. +Retired `/docs/` articles and historical/technical projections are excluded even +when an old HTML file is encountered. Legal and source-resolver routes remain +outside current search. The English Starlight i18n collection supplies the actual +manual-navigation accessible label, rather than an empty warning-suppression file. + +`reconciliation.test.mjs` qualifies the final finite routes, retained-source +captures, resolver behavior, query/fragment handoff, source identities and +source-family preservation. `qa/reconciliation.mjs` adds the joined product/manual +journeys to the existing four wave suites. All screenshots are qualification +material, not a published service, Store image or approved final extension design. + +The two extension and three App chapter markers are retained. `capabilities.json` +keeps published packages separate from source candidates and actual provider +observations. Dashboard and extension experience co-design, Sponsor, public host +publication, Store submission, App OAuth and history stay outside this source cut. + +The source-writing history remains on one draft carrier. Exact-head evidence and +any qualification limits belong in its Return, not a frozen claim in this README. +The programme's independent final review follows the complete reconciliation +candidate; local author checks do not become that fresh review. diff --git a/apps/manual/assets.json b/apps/manual/assets.json new file mode 100644 index 00000000..4d97af98 --- /dev/null +++ b/apps/manual/assets.json @@ -0,0 +1,4 @@ +{ + "apps/browser-extension/assets/diffdevil-brand.svg": "/assets/manual/diffdevil-brand.svg", + "apps/website/src/assets/extension/settings-policy-bands-light.png": "/assets/manual/settings-policy-bands-light.png" +} diff --git a/apps/manual/astro.config.mjs b/apps/manual/astro.config.mjs new file mode 100644 index 00000000..2e57765b --- /dev/null +++ b/apps/manual/astro.config.mjs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import starlightWorks from '@wolfsblvt/starlight-works'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; +import { sidebar, origins } from './manifest.mjs'; +import { engineAliases } from '../website/engine-aliases.mjs'; +import { themeScript } from '../website/src/lib/theme-script.mjs'; +import { diffdevilSyntax } from '../website/src/lib/shiki-theme.ts'; +const root = fileURLToPath(new URL('../../',import.meta.url)); +const shared = path => fileURLToPath(new URL('../website/src/'+path,import.meta.url)); +const own = path => fileURLToPath(new URL('./src/'+path,import.meta.url)); +const faq = existsSync(new URL('../../docs/manual/faq.md',import.meta.url)) && existsSync(new URL('../website/src/pages/faq.astro',import.meta.url)); +export default defineConfig({ + site: origins.docs, output:'static', trailingSlash:'always', + outDir: fileURLToPath(new URL('../../artifacts/manual/dist/',import.meta.url)), + integrations:[starlight({ + title:'diffdevil', description:'The diffdevil manual.', + plugins:[starlightWorks({sidebar:sidebar({includeFAQ:faq})})], + pagefind:false, + customCss:[shared('styles/fonts.css'),shared('styles/tokens.css'),shared('styles/global.css'),shared('styles/starlight.css'),own('styles/reading.css')], + components:{Header:shared('components/docs/Header.astro'),ThemeProvider:shared('components/docs/ThemeProvider.astro'),ThemeSelect:shared('components/docs/ThemeSelect.astro'),SiteTitle:shared('components/docs/SiteTitle.astro'),Footer:own('components/Footer.astro')}, + social:[{icon:'github',label:'GitHub',href:'https://github.com/Wolfsblvt/diffdevil'}], + expressiveCode:{themes:[diffdevilSyntax],useStarlightDarkModeSwitch:false,useStarlightUiThemeColors:false,styleOverrides:{codeFontFamily:'var(--font-mono)',codeFontSize:'13px',codeLineHeight:'1.6',borderRadius:'10px'}}, + head:[{tag:'script',content:themeScript},{tag:'link',attrs:{rel:'icon',href:'/favicon.svg',type:'image/svg+xml'}}], + pagination:true,lastUpdated:false,credits:false,favicon:'/favicon.svg', + })], + markdown:{shikiConfig:{theme:diffdevilSyntax}}, + vite:{resolve:{alias:engineAliases},server:{fs:{allow:[root]}}}, +}); diff --git a/apps/manual/authoring-state.json b/apps/manual/authoring-state.json new file mode 100644 index 00000000..cfd61689 --- /dev/null +++ b/apps/manual/authoring-state.json @@ -0,0 +1,2802 @@ +{ + "version": 1, + "pages": { + "what-is-diffdevil": { + "status": "authored" + }, + "see-changed-lines-on-github": { + "status": "authored" + }, + "label-pull-requests": { + "status": "authored" + }, + "analyze-local-changes": { + "status": "authored" + }, + "use-results-in-scripts": { + "status": "authored" + }, + "try-public-pull-request": { + "status": "authored" + }, + "surfaces": { + "status": "authored" + }, + "cli": { + "status": "authored" + }, + "github-actions": { + "status": "authored" + }, + "browser-extension": { + "status": "authored" + }, + "managed-app": { + "status": "authored" + }, + "managed-service": { + "status": "authored" + }, + "self-host-app": { + "status": "authored" + }, + "typescript-library": { + "status": "authored" + }, + "playground": { + "status": "authored" + }, + "coding-agent": { + "status": "authored" + }, + "shared-workflows": { + "status": "authored" + }, + "reports-plans-and-apply": { + "status": "authored" + }, + "labels-comments-definitions": { + "status": "authored" + }, + "start-with-a-preset": { + "status": "authored" + }, + "configure-policy": { + "status": "authored" + }, + "paths-and-scopes": { + "status": "authored" + }, + "measurements-to-rules": { + "status": "authored" + }, + "effects-and-templates": { + "status": "authored" + }, + "recipes": { + "status": "authored" + }, + "how-diffdevil-reasons": { + "status": "authored" + }, + "changed-lines-and-raw-churn": { + "status": "authored" + }, + "evidence-and-uncertainty": { + "status": "authored" + }, + "from-facts-to-provider-state": { + "status": "authored" + }, + "source-identity-trust-and-mutation": { + "status": "authored" + }, + "interfaces": { + "status": "authored" + }, + "cli-reference": { + "status": "authored" + }, + "github-actions-reference": { + "status": "authored" + }, + "typescript-api-reference": { + "status": "authored" + }, + "language-and-contracts": { + "status": "authored" + }, + "schemas-and-compatibility": { + "status": "authored" + }, + "detail-language": { + "status": "authored" + }, + "faq": { + "status": "external-admitted", + "sourceCommit": "ed795c97b3052441f02f899876122f1967f1f1e2" + }, + "troubleshooting": { + "status": "authored" + }, + "security-and-data": { + "status": "authored" + }, + "technical-project-docs": { + "status": "authored" + }, + "releases": { + "status": "authored" + } + }, + "transfers": { + "docs/guides/auto-label-pull-requests.md": { + "phase": "retired", + "primary": "label-pull-requests", + "fromRef": "65a1a45206909783e38a1309cb0cf7572bc40bca", + "fromSourceSha256": "5ec81e3b74e437725d075562d1aa087fb751b40a1bfbc0de8842ff9df968802f", + "oldAnchors": [ + "_top", + "automatically-label-pull-request-size", + "meaning", + "add-one-workflow", + "what-the-labels-mean", + "why-these-permissions-and-this-event", + "make-one-useful-change", + "read-only-instead", + "troubleshooting" + ], + "anchors": { + "_top": { + "page": "label-pull-requests", + "anchor": "_top" + }, + "automatically-label-pull-request-size": { + "page": "label-pull-requests", + "anchor": "_top" + }, + "meaning": { + "page": "label-pull-requests", + "anchor": "_top" + }, + "add-one-workflow": { + "page": "label-pull-requests", + "anchor": "add-the-complete-workflow" + }, + "what-the-labels-mean": { + "page": "label-pull-requests", + "anchor": "make-it-active-and-inspect-one-result" + }, + "why-these-permissions-and-this-event": { + "page": "github-actions", + "anchor": "select-trusted-configuration-and-credentials" + }, + "make-one-useful-change": { + "page": "label-pull-requests", + "anchor": "customize-only-when-you-have-a-different-question" + }, + "read-only-instead": { + "page": "github-actions", + "anchor": "keep-read-only-analysis-separate" + }, + "troubleshooting": { + "page": "label-pull-requests", + "anchor": "when-no-label-appears" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/guides/local-automation.md": { + "phase": "retired", + "primary": "analyze-local-changes", + "fromRef": "65a1a45206909783e38a1309cb0cf7572bc40bca", + "fromSourceSha256": "ca594ca371af3bd9538214744d4f0b31df12fb5290da2c0548b46f91c2a0d2c4", + "oldAnchors": [ + "_top", + "use-diff-facts-in-local-scripts", + "meaning", + "get-a-first-result-without-github", + "return-one-number", + "find-files-above-a-threshold", + "inspect-your-actual-working-changes", + "analyze-once-query-several-times" + ], + "anchors": { + "_top": { + "page": "analyze-local-changes", + "anchor": "_top" + }, + "use-diff-facts-in-local-scripts": { + "page": "analyze-local-changes", + "anchor": "_top" + }, + "meaning": { + "page": "analyze-local-changes", + "anchor": "_top" + }, + "get-a-first-result-without-github": { + "page": "analyze-local-changes", + "anchor": "run-the-small-patch" + }, + "return-one-number": { + "page": "use-results-in-scripts", + "anchor": "capture-once-and-query-an-exact-value" + }, + "find-files-above-a-threshold": { + "page": "cli", + "anchor": "analyze-query-and-check-the-same-report" + }, + "inspect-your-actual-working-changes": { + "page": "analyze-local-changes", + "anchor": "measure-the-tracked-worktree" + }, + "analyze-once-query-several-times": { + "page": "cli", + "anchor": "analyze-query-and-check-the-same-report" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/integration/playground.md": { + "phase": "retired", + "primary": "playground", + "fromRef": "65a1a45206909783e38a1309cb0cf7572bc40bca", + "fromSourceSha256": "5c6e4c938f860b6b23377368ffaa126e42054e0eb2ca6d02e3d5e737659ac04c", + "oldAnchors": [ + "_top", + "public-playground-experience", + "meaning", + "entry-and-page-structure", + "public-inputs-only", + "one-real-result-with-useful-views", + "configuration-without-a-language-prerequisite", + "curated-examples-with-a-reason", + "export-and-adoption", + "public-compute-github-budget-and-caching", + "website-integration-and-accessibility", + "qualification-boundary", + "provider-reference" + ], + "anchors": { + "_top": { + "page": "playground", + "anchor": "_top" + }, + "public-playground-experience": { + "page": "playground", + "anchor": "_top" + }, + "meaning": { + "page": "playground", + "anchor": "_top" + }, + "entry-and-page-structure": { + "page": "playground", + "anchor": "start-with-a-frozen-source-edition" + }, + "public-inputs-only": { + "page": "playground", + "anchor": "know-which-comparison-you-are-viewing" + }, + "one-real-result-with-useful-views": { + "page": "playground", + "anchor": "read-four-views-of-one-result" + }, + "configuration-without-a-language-prerequisite": { + "page": "playground", + "anchor": "edit-policy-without-changing-the-source" + }, + "curated-examples-with-a-reason": { + "page": "playground", + "anchor": "start-with-a-frozen-source-edition" + }, + "export-and-adoption": { + "page": "playground", + "anchor": "export-a-useful-next-step" + }, + "public-compute-github-budget-and-caching": { + "page": "playground", + "anchor": "data-limits-and-failure-recovery" + }, + "website-integration-and-accessibility": { + "page": "playground", + "anchor": "read-four-views-of-one-result" + }, + "qualification-boundary": { + "page": "playground", + "anchor": "data-limits-and-failure-recovery" + }, + "provider-reference": { + "page": "playground", + "anchor": "data-limits-and-failure-recovery" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/automation.md": { + "phase": "retired", + "primary": "surfaces", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "9b7da24ac8abaede75fccf8fab87d386636eaab2f3725d53ce5f91e8212773dc", + "oldAnchors": [ + "_top", + "use-diffdevil-in-scripts-and-github-actions", + "meaning", + "automatic-size-labels-without-a-config-file", + "default-size-meaning", + "read-only-github-analysis", + "local-analysis-and-values", + "thresholds-and-file-checks", + "matching-paths-and-records", + "a-small-configuration-change", + "add-language-only-where-it-is-useful", + "analyze-once-consume-many-times", + "detailed-integration-references" + ], + "anchors": { + "_top": { + "page": "surfaces", + "anchor": "_top" + }, + "use-diffdevil-in-scripts-and-github-actions": { + "page": "surfaces", + "anchor": "_top" + }, + "meaning": { + "page": "surfaces", + "anchor": "_top" + }, + "automatic-size-labels-without-a-config-file": { + "page": "start-with-a-preset", + "anchor": "see-the-default-result" + }, + "default-size-meaning": { + "page": "start-with-a-preset", + "anchor": "what-size1-selects" + }, + "read-only-github-analysis": { + "page": "surfaces", + "anchor": "_top" + }, + "local-analysis-and-values": { + "page": "surfaces", + "anchor": "_top" + }, + "thresholds-and-file-checks": { + "page": "surfaces", + "anchor": "_top" + }, + "matching-paths-and-records": { + "page": "surfaces", + "anchor": "_top" + }, + "a-small-configuration-change": { + "page": "start-with-a-preset", + "anchor": "choose-the-next-amount-of-policy" + }, + "add-language-only-where-it-is-useful": { + "page": "start-with-a-preset", + "anchor": "choose-the-next-amount-of-policy" + }, + "analyze-once-consume-many-times": { + "page": "shared-workflows", + "anchor": "carry-the-right-thing" + }, + "detailed-integration-references": { + "page": "surfaces", + "anchor": "_top" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/guides/policy-recipes.md": { + "phase": "retired", + "primary": "recipes", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "4e02e785dc72bbeb4944182dc51a03a53b397178f165882f6fb2468d3f94a60f", + "oldAnchors": [ + "_top", + "add-useful-review-signals-and-comments", + "meaning", + "signal-source-changes-without-test-path-changes", + "try-the-same-policy-locally", + "keep-one-useful-comment-up-to-date", + "use-a-threshold-without-a-policy-file", + "deliberately-check-or-synchronize-definitions" + ], + "anchors": { + "_top": { + "page": "recipes", + "anchor": "_top" + }, + "add-useful-review-signals-and-comments": { + "page": "recipes", + "anchor": "_top" + }, + "meaning": { + "page": "recipes", + "anchor": "_top" + }, + "signal-source-changes-without-test-path-changes": { + "page": "recipes", + "anchor": "signal-source-changes-without-test-changes" + }, + "try-the-same-policy-locally": { + "page": "recipes", + "anchor": "signal-source-changes-without-test-changes" + }, + "keep-one-useful-comment-up-to-date": { + "page": "recipes", + "anchor": "preview-one-comment-without-automatic-size-labels" + }, + "use-a-threshold-without-a-policy-file": { + "page": "recipes", + "anchor": "measure-deleted-or-rewritten-lines" + }, + "deliberately-check-or-synchronize-definitions": { + "page": "recipes", + "anchor": "maintain-definitions-deliberately" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/integration/cli.md": { + "phase": "retired", + "primary": "cli", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "884f73c92abe7818d48aaf4c9e26313093aea3e50657e73a13522f4786348aca", + "oldAnchors": [ + "_top", + "cli-integration-contract", + "meaning", + "current-executable-standing", + "current-configured-commands", + "command-surface", + "source-selection", + "configuration-and-semantic-selection", + "query-and-check-selectors", + "output-formats", + "human-and-agent", + "canonical-json", + "exact-scalar-value", + "lines-and-nul-delimited-values", + "json-lines", + "environment-data", + "stdout-stderr-and-output-files", + "exit-statuses", + "powershell-examples", + "bash-examples", + "label-definitions-and-explicit-application", + "discovery-and-validation", + "explicit-github-writes" + ], + "anchors": { + "_top": { + "page": "cli", + "anchor": "_top" + }, + "cli-integration-contract": { + "page": "cli", + "anchor": "_top" + }, + "meaning": { + "page": "cli", + "anchor": "_top" + }, + "current-executable-standing": { + "page": "cli", + "anchor": "_top" + }, + "current-configured-commands": { + "page": "cli-reference", + "anchor": "_top" + }, + "command-surface": { + "page": "cli-reference", + "anchor": "_top" + }, + "source-selection": { + "page": "cli-reference", + "anchor": "invocation-and-source-families" + }, + "configuration-and-semantic-selection": { + "page": "cli", + "anchor": "choose-configuration-deliberately" + }, + "query-and-check-selectors": { + "page": "cli-reference", + "anchor": "query" + }, + "output-formats": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "human-and-agent": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "canonical-json": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "exact-scalar-value": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "lines-and-nul-delimited-values": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "json-lines": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "environment-data": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "stdout-stderr-and-output-files": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "exit-statuses": { + "page": "cli-reference", + "anchor": "exit-codes-and-recovery" + }, + "powershell-examples": { + "page": "cli-reference", + "anchor": "exit-codes-and-recovery" + }, + "bash-examples": { + "page": "cli-reference", + "anchor": "exit-codes-and-recovery" + }, + "label-definitions-and-explicit-application": { + "page": "cli-reference", + "anchor": "labels" + }, + "discovery-and-validation": { + "page": "cli-reference", + "anchor": "validate-and-explain" + }, + "explicit-github-writes": { + "page": "cli-reference", + "anchor": "plan-and-apply" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/integration/github-actions.md": { + "phase": "retired", + "primary": "github-actions", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "c11516ac10b083fddda102dbce869bbaadd591375da6b8c49350f0385cd98ca6", + "oldAnchors": [ + "_top", + "github-actions-integration", + "meaning", + "current-executable-boundary", + "entry-points", + "minimal-complete-workflow", + "split-trusted-policy-reads-from-effects", + "what-the-size-default-manages", + "simple-customization-without-detail", + "core-inputs", + "read-only-outputs", + "analyze-then-revalidate-and-apply-explicitly", + "definition-synchronization", + "trusted-policy-and-hostile-diff-separation", + "concurrency-and-events", + "comments-remain-explicit", + "existing-labels-that-cannot-be-assigned", + "process-result-and-error-transport" + ], + "anchors": { + "_top": { + "page": "github-actions", + "anchor": "_top" + }, + "github-actions-integration": { + "page": "github-actions", + "anchor": "_top" + }, + "meaning": { + "page": "github-actions", + "anchor": "_top" + }, + "current-executable-boundary": { + "page": "github-actions", + "anchor": "_top" + }, + "entry-points": { + "page": "github-actions", + "anchor": "choose-an-entry-point" + }, + "minimal-complete-workflow": { + "page": "github-actions", + "anchor": "add-the-complete-default-workflow" + }, + "split-trusted-policy-reads-from-effects": { + "page": "github-actions", + "anchor": "select-trusted-configuration-and-credentials" + }, + "what-the-size-default-manages": { + "page": "github-actions-reference", + "anchor": "four-entry-points" + }, + "simple-customization-without-detail": { + "page": "github-actions", + "anchor": "select-trusted-configuration-and-credentials" + }, + "core-inputs": { + "page": "github-actions-reference", + "anchor": "exact-inputs-defaults-and-outputs" + }, + "read-only-outputs": { + "page": "github-actions-reference", + "anchor": "exact-inputs-defaults-and-outputs" + }, + "analyze-then-revalidate-and-apply-explicitly": { + "page": "github-actions-reference", + "anchor": "revalidate-before-applying" + }, + "definition-synchronization": { + "page": "github-actions-reference", + "anchor": "trusted-configuration-and-input-combinations" + }, + "trusted-policy-and-hostile-diff-separation": { + "page": "github-actions-reference", + "anchor": "revalidate-before-applying" + }, + "concurrency-and-events": { + "page": "github-actions", + "anchor": "definitions-reruns-and-coexistence" + }, + "comments-remain-explicit": { + "page": "github-actions", + "anchor": "definitions-reruns-and-coexistence" + }, + "existing-labels-that-cannot-be-assigned": { + "page": "github-actions-reference", + "anchor": "process-result-and-recovery" + }, + "process-result-and-error-transport": { + "page": "github-actions-reference", + "anchor": "process-result-and-recovery" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/integration/typescript-api.md": { + "phase": "retired", + "primary": "typescript-library", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "90bb40a5db60501ed635efb084798901ec199555e0f79ed02ab89be02b96a68a", + "oldAnchors": [ + "_top", + "typescript-embedding", + "meaning", + "current-implemented-api", + "executable-policy-api", + "one-package-explicit-boundaries", + "compilation-and-evaluation", + "numeric-measurements", + "structured-shortcuts", + "policies-and-artifacts", + "source-and-diagnostic-types", + "host-supplied-facts", + "runtimetooling-choices", + "testable-integration-promises", + "implemented-github-api" + ], + "anchors": { + "_top": { + "page": "typescript-library", + "anchor": "_top" + }, + "typescript-embedding": { + "page": "typescript-library", + "anchor": "_top" + }, + "meaning": { + "page": "typescript-library", + "anchor": "_top" + }, + "current-implemented-api": { + "page": "typescript-library", + "anchor": "_top" + }, + "executable-policy-api": { + "page": "typescript-library", + "anchor": "compile-policy-once-evaluate-the-intended-lifecycle" + }, + "one-package-explicit-boundaries": { + "page": "typescript-library", + "anchor": "install-a-public-entry" + }, + "compilation-and-evaluation": { + "page": "typescript-library", + "anchor": "compile-policy-once-evaluate-the-intended-lifecycle" + }, + "numeric-measurements": { + "page": "typescript-library", + "anchor": "separate-result-success-from-evidence" + }, + "structured-shortcuts": { + "page": "typescript-api-reference", + "anchor": "analysis-policy-and-query-lifecycle" + }, + "policies-and-artifacts": { + "page": "typescript-library", + "anchor": "serialize-data-not-trust" + }, + "source-and-diagnostic-types": { + "page": "typescript-api-reference", + "anchor": "result-evidence-and-diagnostics" + }, + "host-supplied-facts": { + "page": "typescript-library", + "anchor": "add-acquisition-and-effects-only-when-the-host-needs-them" + }, + "runtimetooling-choices": { + "page": "typescript-library", + "anchor": "update-and-diagnose-at-the-package-boundary" + }, + "testable-integration-promises": { + "page": "typescript-library", + "anchor": "update-and-diagnose-at-the-package-boundary" + }, + "implemented-github-api": { + "page": "typescript-library", + "anchor": "add-acquisition-and-effects-only-when-the-host-needs-them" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/integration/presets-and-shortcuts.md": { + "phase": "retired", + "primary": "start-with-a-preset", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "64162c12bb8be2bd19ff2830a60bcb467648cc9261c301a0444623b39ec6592b", + "oldAnchors": [ + "_top", + "presets-and-shortcuts", + "meaning", + "the-three-authoring-levels", + "preset-identity-and-default-selection", + "default-size-policy", + "small-size-overrides", + "configuration-layering", + "current-compiler-and-invocation-behavior", + "one-conventional-repository-file", + "selected-host-specific-resolution", + "partial-settings-without-accidental-policy-merging", + "explain-and-export-the-result", + "metric-shortcut-catalog", + "cli-scalar-and-threshold-lowering", + "file-shortcut-lowering", + "scopes-and-immediate-paths", + "action-shortcuts", + "expression-selectors", + "growth-without-a-rewrite" + ], + "anchors": { + "_top": { + "page": "start-with-a-preset", + "anchor": "_top" + }, + "presets-and-shortcuts": { + "page": "start-with-a-preset", + "anchor": "_top" + }, + "meaning": { + "page": "start-with-a-preset", + "anchor": "_top" + }, + "the-three-authoring-levels": { + "page": "start-with-a-preset", + "anchor": "see-the-default-result" + }, + "preset-identity-and-default-selection": { + "page": "start-with-a-preset", + "anchor": "select-it-explicitly" + }, + "default-size-policy": { + "page": "start-with-a-preset", + "anchor": "what-size1-selects" + }, + "small-size-overrides": { + "page": "start-with-a-preset", + "anchor": "choose-the-next-amount-of-policy" + }, + "configuration-layering": { + "page": "configure-policy", + "anchor": "know-which-host-supplies-each-layer" + }, + "current-compiler-and-invocation-behavior": { + "page": "configure-policy", + "anchor": "inspect-effective-configuration" + }, + "one-conventional-repository-file": { + "page": "configure-policy", + "anchor": "know-which-host-supplies-each-layer" + }, + "selected-host-specific-resolution": { + "page": "configure-policy", + "anchor": "selected-host-layering-and-current-limitations" + }, + "partial-settings-without-accidental-policy-merging": { + "page": "configure-policy", + "anchor": "selected-host-layering-and-current-limitations" + }, + "explain-and-export-the-result": { + "page": "start-with-a-preset", + "anchor": "inspect-the-complete-expansion" + }, + "metric-shortcut-catalog": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "cli-scalar-and-threshold-lowering": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "file-shortcut-lowering": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "scopes-and-immediate-paths": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "action-shortcuts": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "expression-selectors": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "growth-without-a-rewrite": { + "page": "start-with-a-preset", + "anchor": "choose-the-next-amount-of-policy" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/integration/templates.md": { + "phase": "retired", + "primary": "effects-and-templates", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "be37928242e38609b46700cb0b7c860eadf9b14aefec575c68af6f23af3902d8", + "oldAnchors": [ + "_top", + "optional-comment-templates", + "meaning", + "placeholder-syntax", + "display-semantics", + "validation-and-source-positions", + "comment-configuration", + "ownership-and-identity", + "provider-boundaries", + "operational-writing", + "implemented-provider-ownership-and-lifecycle-state" + ], + "anchors": { + "_top": { + "page": "effects-and-templates", + "anchor": "_top" + }, + "optional-comment-templates": { + "page": "effects-and-templates", + "anchor": "_top" + }, + "meaning": { + "page": "effects-and-templates", + "anchor": "_top" + }, + "placeholder-syntax": { + "page": "effects-and-templates", + "anchor": "substitute-data-not-expressions" + }, + "display-semantics": { + "page": "effects-and-templates", + "anchor": "substitute-data-not-expressions" + }, + "validation-and-source-positions": { + "page": "effects-and-templates", + "anchor": "substitute-data-not-expressions" + }, + "comment-configuration": { + "page": "effects-and-templates", + "anchor": "select-lifecycle-trigger-and-occasion" + }, + "ownership-and-identity": { + "page": "effects-and-templates", + "anchor": "select-lifecycle-trigger-and-occasion" + }, + "provider-boundaries": { + "page": "effects-and-templates", + "anchor": "apply-through-the-selected-host" + }, + "operational-writing": { + "page": "effects-and-templates", + "anchor": "apply-through-the-selected-host" + }, + "implemented-provider-ownership-and-lifecycle-state": { + "page": "effects-and-templates", + "anchor": "select-lifecycle-trigger-and-occasion" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language.md": { + "phase": "retired", + "primary": "language-and-contracts", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "308129af28a56cbb2fa7181493a94554e3c2da6a6b4852c8528b29593d4133f6", + "oldAnchors": [ + "_top", + "detail-the-diffdevil-expression-language", + "meaning", + "start-without-learning-a-language", + "a-first-formula", + "a-first-condition", + "a-first-query", + "four-contexts-one-meaning", + "explicit-roots", + "names-and-literals", + "evidence-is-part-of-the-value", + "beyond-the-basics", + "design-limits" + ], + "anchors": { + "_top": { + "page": "language-and-contracts", + "anchor": "_top" + }, + "detail-the-diffdevil-expression-language": { + "page": "language-and-contracts", + "anchor": "_top" + }, + "meaning": { + "page": "language-and-contracts", + "anchor": "_top" + }, + "start-without-learning-a-language": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "a-first-formula": { + "page": "measurements-to-rules", + "anchor": "separate-measures-formulas-and-scopes" + }, + "a-first-condition": { + "page": "measurements-to-rules", + "anchor": "query-then-check" + }, + "a-first-query": { + "page": "measurements-to-rules", + "anchor": "query-then-check" + }, + "four-contexts-one-meaning": { + "page": "language-and-contracts", + "anchor": "expressions-have-phases-too" + }, + "explicit-roots": { + "page": "language-and-contracts", + "anchor": "expressions-have-phases-too" + }, + "names-and-literals": { + "page": "language-and-contracts", + "anchor": "expressions-have-phases-too" + }, + "evidence-is-part-of-the-value": { + "page": "language-and-contracts", + "anchor": "envelopes-do-not-grant-execution" + }, + "beyond-the-basics": { + "page": "language-and-contracts", + "anchor": "which-contract-owns-the-question" + }, + "design-limits": { + "page": "language-and-contracts", + "anchor": "envelopes-do-not-grant-execution" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/policies-and-bands.md": { + "phase": "retired", + "primary": "measurements-to-rules", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "a591f162746e6897654923a4119dab42f85880bd74c523c82e38ce364dac5a4c", + "oldAnchors": [ + "_top", + "policies-metrics-bands-and-rules", + "meaning", + "configuration-entry", + "main-sections", + "metrics", + "standard-measure-catalog", + "dependency-graph-and-phases", + "parameters", + "bands", + "validation", + "resolution-with-evidence", + "rules", + "labels-and-conflicts", + "comments-and-templates", + "plan-boundary", + "complete-specimens-and-evolution", + "current-implementation-standing" + ], + "anchors": { + "_top": { + "page": "measurements-to-rules", + "anchor": "_top" + }, + "policies-metrics-bands-and-rules": { + "page": "measurements-to-rules", + "anchor": "_top" + }, + "meaning": { + "page": "measurements-to-rules", + "anchor": "_top" + }, + "configuration-entry": { + "page": "measurements-to-rules", + "anchor": "name-the-question-in-a-complete-policy" + }, + "main-sections": { + "page": "measurements-to-rules", + "anchor": "name-the-question-in-a-complete-policy" + }, + "metrics": { + "page": "measurements-to-rules", + "anchor": "separate-measures-formulas-and-scopes" + }, + "standard-measure-catalog": { + "page": "measurements-to-rules", + "anchor": "separate-measures-formulas-and-scopes" + }, + "dependency-graph-and-phases": { + "page": "measurements-to-rules", + "anchor": "respect-evaluation-phases" + }, + "parameters": { + "page": "measurements-to-rules", + "anchor": "name-the-question-in-a-complete-policy" + }, + "bands": { + "page": "measurements-to-rules", + "anchor": "classify-the-whole-possible-value" + }, + "validation": { + "page": "measurements-to-rules", + "anchor": "classify-the-whole-possible-value" + }, + "resolution-with-evidence": { + "page": "measurements-to-rules", + "anchor": "classify-the-whole-possible-value" + }, + "rules": { + "page": "measurements-to-rules", + "anchor": "declare-and-inspect-a-rule" + }, + "labels-and-conflicts": { + "page": "measurements-to-rules", + "anchor": "declare-and-inspect-a-rule" + }, + "comments-and-templates": { + "page": "measurements-to-rules", + "anchor": "declare-and-inspect-a-rule" + }, + "plan-boundary": { + "page": "measurements-to-rules", + "anchor": "declare-and-inspect-a-rule" + }, + "complete-specimens-and-evolution": { + "page": "measurements-to-rules", + "anchor": "_top" + }, + "current-implementation-standing": { + "page": "measurements-to-rules", + "anchor": "_top" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/versioning-and-interchange.md": { + "phase": "retired", + "primary": "schemas-and-compatibility", + "fromRef": "a9df2c151da1f3bc36e4d2f4608ace62eca6d330", + "fromSourceSha256": "5ad97e09a59a859c7f90f8dba1e06c45cea6cc48d3c3d6d7701d150a76774ca9", + "oldAnchors": [ + "_top", + "versioning-and-interchange", + "meaning", + "separate-versioned-meanings", + "textual-presentation-identities", + "report-envelope", + "files-and-completeness", + "query-result-encoding", + "report-jsonl-and-compact-action-summaries", + "plan-encoding", + "source-identity-and-freshness", + "forward-compatible-consumption", + "no-silent-semantic-drift", + "persist-authored-source-not-parser-internals", + "migration-posture", + "schema-scope-and-qualification", + "pre-release-saved-metric-type-repair" + ], + "anchors": { + "_top": { + "page": "schemas-and-compatibility", + "anchor": "_top" + }, + "versioning-and-interchange": { + "page": "schemas-and-compatibility", + "anchor": "_top" + }, + "meaning": { + "page": "schemas-and-compatibility", + "anchor": "_top" + }, + "separate-versioned-meanings": { + "page": "schemas-and-compatibility", + "anchor": "version-identities" + }, + "textual-presentation-identities": { + "page": "schemas-and-compatibility", + "anchor": "version-identities" + }, + "report-envelope": { + "page": "schemas-and-compatibility", + "anchor": "report-and-query-envelopes" + }, + "files-and-completeness": { + "page": "schemas-and-compatibility", + "anchor": "report-and-query-envelopes" + }, + "query-result-encoding": { + "page": "schemas-and-compatibility", + "anchor": "report-and-query-envelopes" + }, + "report-jsonl-and-compact-action-summaries": { + "page": "schemas-and-compatibility", + "anchor": "transport-distinctions" + }, + "plan-encoding": { + "page": "schemas-and-compatibility", + "anchor": "plans-and-effects" + }, + "source-identity-and-freshness": { + "page": "schemas-and-compatibility", + "anchor": "plans-and-effects" + }, + "forward-compatible-consumption": { + "page": "schemas-and-compatibility", + "anchor": "forward-reading-and-strict-execution" + }, + "no-silent-semantic-drift": { + "page": "schemas-and-compatibility", + "anchor": "forward-reading-and-strict-execution" + }, + "persist-authored-source-not-parser-internals": { + "page": "schemas-and-compatibility", + "anchor": "forward-reading-and-strict-execution" + }, + "migration-posture": { + "page": "schemas-and-compatibility", + "anchor": "migration-standing" + }, + "schema-scope-and-qualification": { + "page": "schemas-and-compatibility", + "anchor": "structural-versus-semantic-reading" + }, + "pre-release-saved-metric-type-repair": { + "page": "schemas-and-compatibility", + "anchor": "migration-standing" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/syntax.md": { + "phase": "retired", + "fromRef": "c708cebe95fd9325f7ef548148ab9d24b31b2f7e", + "fromSourceSha256": "d7e3184917ec2833b3bb918b9b424c9289771c0fc4b67da9f3a6a69bea8e032c", + "oldAnchors": [ + "_top", + "detail-syntax", + "meaning", + "source-unit-and-character-model", + "identifiers-and-reserved-words", + "numbers", + "strings", + "operators-and-precedence", + "property-access", + "calls-and-lambda-binders", + "lists-and-records", + "conditional-expressions", + "complete-grammar-and-ast-contract", + "syntax-is-not-the-sandbox" + ], + "primary": "detail-language", + "anchors": { + "_top": { + "page": "detail-language", + "anchor": "_top" + }, + "detail-syntax": { + "page": "detail-language", + "anchor": "syntax-and-source-locations" + }, + "meaning": { + "page": "detail-language", + "anchor": "_top" + }, + "source-unit-and-character-model": { + "page": "detail-language", + "anchor": "source-unit-and-character-model" + }, + "identifiers-and-reserved-words": { + "page": "detail-language", + "anchor": "identifiers-and-reserved-words" + }, + "numbers": { + "page": "detail-language", + "anchor": "numbers" + }, + "strings": { + "page": "detail-language", + "anchor": "strings" + }, + "operators-and-precedence": { + "page": "detail-language", + "anchor": "operators-and-precedence" + }, + "property-access": { + "page": "detail-language", + "anchor": "property-access" + }, + "calls-and-lambda-binders": { + "page": "detail-language", + "anchor": "calls-and-lambda-binders" + }, + "lists-and-records": { + "page": "detail-language", + "anchor": "lists-and-records" + }, + "conditional-expressions": { + "page": "detail-language", + "anchor": "conditional-expressions" + }, + "complete-grammar-and-ast-contract": { + "page": "detail-language", + "anchor": "complete-grammar-and-ast-contract" + }, + "syntax-is-not-the-sandbox": { + "page": "detail-language", + "anchor": "syntax-is-not-the-sandbox" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/types-and-measurements.md": { + "phase": "retired", + "fromRef": "c708cebe95fd9325f7ef548148ab9d24b31b2f7e", + "fromSourceSha256": "62100df469347dd3de057541685d6fc965cd55ec910b9f025c7ff3276b1102b0", + "oldAnchors": [ + "_top", + "types-measurements-and-decisions", + "meaning", + "static-types-and-runtime-evidence", + "numeric-domains", + "numeric-evidence-states", + "structural-absence-null-uncertainty-and-errors", + "optional-values-and-guards", + "arithmetic-over-intervals", + "primitive-correlations", + "numeric-comparisons", + "boolean-algebra-and-evaluation-order", + "conditional-values", + "explicit-projections-and-requirements", + "deterministic-evidence-and-diagnostics", + "file-counter-population" + ], + "primary": "detail-language", + "anchors": { + "_top": { + "page": "detail-language", + "anchor": "_top" + }, + "types-measurements-and-decisions": { + "page": "detail-language", + "anchor": "values-types-and-measurements" + }, + "meaning": { + "page": "detail-language", + "anchor": "_top" + }, + "static-types-and-runtime-evidence": { + "page": "detail-language", + "anchor": "static-types-and-runtime-evidence" + }, + "numeric-domains": { + "page": "detail-language", + "anchor": "numeric-domains" + }, + "numeric-evidence-states": { + "page": "detail-language", + "anchor": "numeric-evidence-states" + }, + "structural-absence-null-uncertainty-and-errors": { + "page": "detail-language", + "anchor": "structural-absence-null-uncertainty-and-errors" + }, + "optional-values-and-guards": { + "page": "detail-language", + "anchor": "optional-values-and-guards" + }, + "arithmetic-over-intervals": { + "page": "detail-language", + "anchor": "arithmetic-over-intervals" + }, + "primitive-correlations": { + "page": "detail-language", + "anchor": "primitive-correlations" + }, + "numeric-comparisons": { + "page": "detail-language", + "anchor": "numeric-comparisons" + }, + "boolean-algebra-and-evaluation-order": { + "page": "detail-language", + "anchor": "boolean-algebra-and-evaluation-order" + }, + "conditional-values": { + "page": "detail-language", + "anchor": "conditional-values" + }, + "explicit-projections-and-requirements": { + "page": "detail-language", + "anchor": "explicit-projections-and-requirements" + }, + "deterministic-evidence-and-diagnostics": { + "page": "detail-language", + "anchor": "deterministic-evidence-and-diagnostics" + }, + "file-counter-population": { + "page": "detail-language", + "anchor": "file-counter-population" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/collections-and-scopes.md": { + "phase": "retired", + "fromRef": "c708cebe95fd9325f7ef548148ab9d24b31b2f7e", + "fromSourceSha256": "f6e75e1feb6c6787535c8f1ec16e64000a0c50f89e4c4a614ba24df669b8769c", + "oldAnchors": [ + "_top", + "collections-scopes-and-paths", + "meaning", + "logical-collection-model", + "stable-file-order", + "filtering", + "mapping", + "any-and-all", + "count", + "sum-and-numeric-aggregation", + "sorting-and-taking-a-prefix", + "explicit-definite-only-projection", + "included-files-and-scopes", + "path-matching-profile", + "renames", + "strict-output-boundary", + "cardinality-constraints-and-deterministic-prefix-precision" + ], + "primary": "detail-language", + "anchors": { + "_top": { + "page": "detail-language", + "anchor": "_top" + }, + "collections-scopes-and-paths": { + "page": "detail-language", + "anchor": "collections" + }, + "meaning": { + "page": "detail-language", + "anchor": "_top" + }, + "logical-collection-model": { + "page": "detail-language", + "anchor": "logical-collection-model" + }, + "stable-file-order": { + "page": "detail-language", + "anchor": "stable-file-order" + }, + "filtering": { + "page": "detail-language", + "anchor": "filtering" + }, + "mapping": { + "page": "detail-language", + "anchor": "mapping" + }, + "any-and-all": { + "page": "detail-language", + "anchor": "any-and-all" + }, + "count": { + "page": "detail-language", + "anchor": "count" + }, + "sum-and-numeric-aggregation": { + "page": "detail-language", + "anchor": "sum-and-numeric-aggregation" + }, + "sorting-and-taking-a-prefix": { + "page": "detail-language", + "anchor": "sorting-and-taking-a-prefix" + }, + "explicit-definite-only-projection": { + "page": "detail-language", + "anchor": "explicit-definite-only-projection" + }, + "included-files-and-scopes": { + "page": "detail-language", + "anchor": "included-files-and-scopes" + }, + "path-matching-profile": { + "page": "detail-language", + "anchor": "path-matching-profile" + }, + "renames": { + "page": "detail-language", + "anchor": "renames" + }, + "strict-output-boundary": { + "page": "detail-language", + "anchor": "strict-output-boundary" + }, + "cardinality-constraints-and-deterministic-prefix-precision": { + "page": "detail-language", + "anchor": "cardinality-constraints-and-deterministic-prefix-precision" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/standard-library.md": { + "phase": "retired", + "fromRef": "c708cebe95fd9325f7ef548148ab9d24b31b2f7e", + "fromSourceSha256": "d33afa34ee290d76e6ad20af1b12264dd4694d90ea20bef76c80553d0d79fd97", + "oldAnchors": [ + "_top", + "detail-standard-library", + "meaning", + "evidence-and-optional-values", + "statusvalue-n---string", + "isexactvalue-n---boolean", + "lowerboundvalue-n---n-and-upperboundvalue-n---n", + "requireexactvalue-n---n", + "ismissingvalue-optionalt---boolean", + "isnullvalue-optionalt---boolean", + "requirepresentvalue-optionalt---t", + "orelsevalue-optionalt-fallback-t---t", + "numeric-helpers", + "absvalue-n---n", + "floorvalue-n---integer-and-ceilvalue-n---integer", + "roundvalue-n-digits-integer---float", + "collection-operations", + "filteritems-ct-predicate-t---boolean---ct", + "mapitems-ct-selector-t---u---cu", + "anyitems-ct-predicate-t---boolean---boolean", + "allitems-ct-predicate-t---boolean---boolean", + "countitems-ct---integer", + "sumitems-cn---n", + "sumitems-ct-selector-t---n---n", + "minitems-cn---optionaln", + "minitems-ct-selector-t---n---optionaln", + "maxitems-cn---optionaln", + "maxitems-ct-selector-t---n---optionaln", + "avgitems-cn---optionalfloat", + "avgitems-ct-selector-t---n---optionalfloat", + "sortbyitems-ct-key-t---k--direction-string---ct", + "takeitems-ct-count-integer---ct", + "certainitems-ct---ct", + "string-and-path-helpers", + "startswithtext-string-prefix-string---boolean", + "endswithtext-string-suffix-string---boolean", + "containstext-string-fragment-string---boolean", + "lengthtext-string---integer", + "globpath-string-pattern-string---boolean", + "pathmatchesfile-file-pattern-string---boolean", + "membership-operator", + "deliberately-absent-facilities", + "errors-and-discovery" + ], + "primary": "detail-language", + "anchors": { + "_top": { + "page": "detail-language", + "anchor": "_top" + }, + "detail-standard-library": { + "page": "detail-language", + "anchor": "functions" + }, + "meaning": { + "page": "detail-language", + "anchor": "_top" + }, + "evidence-and-optional-values": { + "page": "detail-language", + "anchor": "evidence-and-optional-values" + }, + "statusvalue-n---string": { + "page": "detail-language", + "anchor": "statusvalue-n---string" + }, + "isexactvalue-n---boolean": { + "page": "detail-language", + "anchor": "isexactvalue-n---boolean" + }, + "lowerboundvalue-n---n-and-upperboundvalue-n---n": { + "page": "detail-language", + "anchor": "lowerboundvalue-n---n-and-upperboundvalue-n---n" + }, + "requireexactvalue-n---n": { + "page": "detail-language", + "anchor": "requireexactvalue-n---n" + }, + "ismissingvalue-optionalt---boolean": { + "page": "detail-language", + "anchor": "ismissingvalue-optionalt---boolean" + }, + "isnullvalue-optionalt---boolean": { + "page": "detail-language", + "anchor": "isnullvalue-optionalt---boolean" + }, + "requirepresentvalue-optionalt---t": { + "page": "detail-language", + "anchor": "requirepresentvalue-optionalt---t" + }, + "orelsevalue-optionalt-fallback-t---t": { + "page": "detail-language", + "anchor": "orelsevalue-optionalt-fallback-t---t" + }, + "numeric-helpers": { + "page": "detail-language", + "anchor": "numeric-helpers" + }, + "absvalue-n---n": { + "page": "detail-language", + "anchor": "absvalue-n---n" + }, + "floorvalue-n---integer-and-ceilvalue-n---integer": { + "page": "detail-language", + "anchor": "floorvalue-n---integer-and-ceilvalue-n---integer" + }, + "roundvalue-n-digits-integer---float": { + "page": "detail-language", + "anchor": "roundvalue-n-digits-integer---float" + }, + "collection-operations": { + "page": "detail-language", + "anchor": "collection-operations" + }, + "filteritems-ct-predicate-t---boolean---ct": { + "page": "detail-language", + "anchor": "filteritems-ct-predicate-t---boolean---ct" + }, + "mapitems-ct-selector-t---u---cu": { + "page": "detail-language", + "anchor": "mapitems-ct-selector-t---u---cu" + }, + "anyitems-ct-predicate-t---boolean---boolean": { + "page": "detail-language", + "anchor": "anyitems-ct-predicate-t---boolean---boolean" + }, + "allitems-ct-predicate-t---boolean---boolean": { + "page": "detail-language", + "anchor": "allitems-ct-predicate-t---boolean---boolean" + }, + "countitems-ct---integer": { + "page": "detail-language", + "anchor": "countitems-ct---integer" + }, + "sumitems-cn---n": { + "page": "detail-language", + "anchor": "sumitems-cn---n" + }, + "sumitems-ct-selector-t---n---n": { + "page": "detail-language", + "anchor": "sumitems-ct-selector-t---n---n" + }, + "minitems-cn---optionaln": { + "page": "detail-language", + "anchor": "minitems-cn---optionaln" + }, + "minitems-ct-selector-t---n---optionaln": { + "page": "detail-language", + "anchor": "minitems-ct-selector-t---n---optionaln" + }, + "maxitems-cn---optionaln": { + "page": "detail-language", + "anchor": "maxitems-cn---optionaln" + }, + "maxitems-ct-selector-t---n---optionaln": { + "page": "detail-language", + "anchor": "maxitems-ct-selector-t---n---optionaln" + }, + "avgitems-cn---optionalfloat": { + "page": "detail-language", + "anchor": "avgitems-cn---optionalfloat" + }, + "avgitems-ct-selector-t---n---optionalfloat": { + "page": "detail-language", + "anchor": "avgitems-ct-selector-t---n---optionalfloat" + }, + "sortbyitems-ct-key-t---k--direction-string---ct": { + "page": "detail-language", + "anchor": "sortbyitems-ct-key-t---k--direction-string---ct" + }, + "takeitems-ct-count-integer---ct": { + "page": "detail-language", + "anchor": "takeitems-ct-count-integer---ct" + }, + "certainitems-ct---ct": { + "page": "detail-language", + "anchor": "certainitems-ct---ct" + }, + "string-and-path-helpers": { + "page": "detail-language", + "anchor": "string-and-path-helpers" + }, + "startswithtext-string-prefix-string---boolean": { + "page": "detail-language", + "anchor": "startswithtext-string-prefix-string---boolean" + }, + "endswithtext-string-suffix-string---boolean": { + "page": "detail-language", + "anchor": "endswithtext-string-suffix-string---boolean" + }, + "containstext-string-fragment-string---boolean": { + "page": "detail-language", + "anchor": "containstext-string-fragment-string---boolean" + }, + "lengthtext-string---integer": { + "page": "detail-language", + "anchor": "lengthtext-string---integer" + }, + "globpath-string-pattern-string---boolean": { + "page": "detail-language", + "anchor": "globpath-string-pattern-string---boolean" + }, + "pathmatchesfile-file-pattern-string---boolean": { + "page": "detail-language", + "anchor": "pathmatchesfile-file-pattern-string---boolean" + }, + "membership-operator": { + "page": "detail-language", + "anchor": "membership-operator" + }, + "deliberately-absent-facilities": { + "page": "detail-language", + "anchor": "deliberately-absent-facilities" + }, + "errors-and-discovery": { + "page": "detail-language", + "anchor": "errors-and-discovery" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/language/diagnostics-and-limits.md": { + "phase": "retired", + "fromRef": "c708cebe95fd9325f7ef548148ab9d24b31b2f7e", + "fromSourceSha256": "ad1ec0c8e7668732be06014a972c6ab3126629cba6327945286f1733e22e4979", + "oldAnchors": [ + "_top", + "diagnostics-deterministic-limits-and-execution-boundaries", + "meaning", + "diagnostic-phases", + "stable-codes-not-frozen-sentences", + "coordinates", + "error-collection-and-ordering", + "limit-profile", + "work-accounting", + "parser-and-matcher-complexity", + "inert-input-boundary", + "no-ambient-capabilities", + "effects-and-hostile-inputs", + "release-qualification" + ], + "primary": "detail-language", + "anchors": { + "_top": { + "page": "detail-language", + "anchor": "_top" + }, + "diagnostics-deterministic-limits-and-execution-boundaries": { + "page": "detail-language", + "anchor": "diagnostics" + }, + "meaning": { + "page": "detail-language", + "anchor": "_top" + }, + "diagnostic-phases": { + "page": "detail-language", + "anchor": "diagnostics" + }, + "stable-codes-not-frozen-sentences": { + "page": "troubleshooting", + "anchor": "report-a-useful-problem-safely" + }, + "coordinates": { + "page": "troubleshooting", + "anchor": "a-diagnostic-points-into-yaml-an-expression-or-a-unicode-line" + }, + "error-collection-and-ordering": { + "page": "troubleshooting", + "anchor": "a-diagnostic-points-into-yaml-an-expression-or-a-unicode-line" + }, + "limit-profile": { + "page": "detail-language", + "anchor": "deterministic-limits" + }, + "work-accounting": { + "page": "detail-language", + "anchor": "deterministic-limits" + }, + "parser-and-matcher-complexity": { + "page": "detail-language", + "anchor": "deterministic-limits" + }, + "inert-input-boundary": { + "page": "detail-language", + "anchor": "syntax-is-not-the-sandbox" + }, + "no-ambient-capabilities": { + "page": "detail-language", + "anchor": "syntax-is-not-the-sandbox" + }, + "effects-and-hostile-inputs": { + "page": "troubleshooting", + "anchor": "labels-or-comments-disagree-with-a-successful-analysis" + }, + "release-qualification": { + "page": "troubleshooting", + "anchor": "report-a-useful-problem-safely" + } + }, + "redirects": true, + "sourceIds": true, + "search": true + }, + "docs/releases/README.md": { + "phase": "maintainer-map", + "fromRef": "c708cebe95fd9325f7ef548148ab9d24b31b2f7e", + "fromSourceSha256": "1ac359302bd56aabd740e26b5093c94fbb082c7a79867bea688539be706ec079", + "oldAnchors": [ + "_top", + "diffdevil-releases", + "meaning", + "releases" + ], + "primary": "releases", + "anchors": { + "_top": { + "page": "releases", + "anchor": "_top" + }, + "diffdevil-releases": { + "page": "releases", + "anchor": "_top" + }, + "meaning": { + "page": "releases", + "anchor": "_top" + }, + "releases": { + "page": "releases", + "anchor": "compatibility-and-historical-accounts" + } + }, + "redirects": true, + "sourceIds": true, + "search": true, + "residueJob": "Maintainer index of dated release notes; current distribution and upgrade guidance belongs to the manual Releases page." + } + }, + "routes": { + "/docs/get-started/auto-label-pull-requests/": { + "target": "label-pull-requests" + }, + "/docs/get-started/local-automation/": { + "target": "analyze-local-changes" + }, + "/docs/playground/": { + "target": "playground" + }, + "/docs/get-started/automation/": { + "target": "surfaces" + }, + "/docs/recipes/policy-recipes/": { + "target": "recipes" + }, + "/docs/cli/": { + "target": "cli" + }, + "/docs/actions/github-actions/": { + "target": "github-actions" + }, + "/docs/library-api/": { + "target": "typescript-library" + }, + "/docs/policies-and-detail/presets-and-shortcuts/": { + "target": "start-with-a-preset" + }, + "/docs/policies-and-detail/templates/": { + "target": "effects-and-templates" + }, + "/docs/policies-and-detail/language/": { + "target": "language-and-contracts" + }, + "/docs/policies-and-detail/policies-and-bands/": { + "target": "measurements-to-rules" + }, + "/docs/policies-and-detail/versioning-and-interchange/": { + "target": "schemas-and-compatibility" + }, + "/docs/github-app/architecture/": { + "target": "managed-app", + "projection": { + "source": "docs/integration/github-app.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "f82a6e04a6c1112eba04d4ade16ffef15bfa8c9873b6a39dca7b1b9e60422b4c", + "oldAnchors": [ + "_top", + "meaning", + "product-and-adoption-contract", + "configuration-and-dashboard", + "native-github-checks-are-selected-functionality", + "service-and-repository-boundaries", + "first-operating-adapter-and-portability", + "shared-engine-worker-prerequisite", + "delivery-flow", + "webhook-admission-and-authentication", + "permissions-and-current-access", + "delivery-identity-and-per-pr-serialization", + "trusted-policy-and-freshness", + "comment-ownership-and-migration", + "data-history-and-commercial-boundaries", + "resource-cost-and-failure-boundaries", + "qualification-and-completion", + "current-standing", + "provider-references", + "related-questions" + ], + "anchors": { + "_top": { + "page": "managed-app", + "anchor": "_top" + }, + "meaning": { + "page": "managed-app", + "anchor": "_top" + }, + "product-and-adoption-contract": { + "page": "managed-app", + "anchor": "_top" + }, + "configuration-and-dashboard": { + "page": "managed-app", + "anchor": "your-policy-remains-yours" + }, + "native-github-checks-are-selected-functionality": { + "page": "managed-app", + "anchor": "what-happens-on-a-pull-request" + }, + "service-and-repository-boundaries": { + "page": "managed-app", + "anchor": "coexist-with-your-other-tools" + }, + "first-operating-adapter-and-portability": { + "page": "self-host-app", + "anchor": "_top" + }, + "shared-engine-worker-prerequisite": { + "page": "self-host-app", + "anchor": "start-from-a-complete-source-checkout" + }, + "delivery-flow": { + "page": "managed-app", + "anchor": "what-happens-on-a-pull-request" + }, + "webhook-admission-and-authentication": { + "page": "self-host-app", + "anchor": "register-the-intended-github-app" + }, + "permissions-and-current-access": { + "page": "security-and-data", + "anchor": "authorization-and-writes-in-the-app" + }, + "delivery-identity-and-per-pr-serialization": { + "page": "self-host-app", + "anchor": "observe-and-repair-by-stage" + }, + "trusted-policy-and-freshness": { + "page": "managed-service", + "anchor": "inspect-the-effective-policy" + }, + "comment-ownership-and-migration": { + "page": "managed-service", + "anchor": "move-between-actions-and-the-app" + }, + "data-history-and-commercial-boundaries": { + "page": "managed-app", + "anchor": "history-without-a-repository-archive" + }, + "resource-cost-and-failure-boundaries": { + "page": "self-host-app", + "anchor": "give-each-provider-resource-its-real-job" + }, + "qualification-and-completion": { + "page": "managed-app", + "anchor": "availability-and-release-boundaries" + }, + "current-standing": { + "page": "managed-app", + "anchor": "availability-and-release-boundaries" + }, + "provider-references": { + "page": "technical-project-docs", + "anchor": "operate-an-application" + }, + "related-questions": { + "page": "managed-app", + "anchor": "related-questions" + } + } + } + }, + "/docs/setup/app/": { + "target": "managed-service", + "projection": { + "source": "docs/setup/app.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "f68b20c83bc6ddf1642a3fa5dc8cb0578f4f7f857d3091279071fca5a20c7f8b", + "oldAnchors": [ + "_top", + "meaning", + "identify-the-service-and-target", + "explain-the-selected-behavior", + "complete-the-actual-installation", + "configure-preview-and-enable", + "reconcile-existing-writers", + "verify-one-useful-managed-result", + "self-hosting", + "return-the-result", + "deeper-reference" + ], + "anchors": { + "_top": { + "page": "managed-service", + "anchor": "_top" + }, + "meaning": { + "page": "managed-service", + "anchor": "_top" + }, + "identify-the-service-and-target": { + "page": "managed-service", + "anchor": "establish-the-account-and-repository" + }, + "explain-the-selected-behavior": { + "page": "managed-service", + "anchor": "inspect-the-effective-policy" + }, + "complete-the-actual-installation": { + "page": "managed-service", + "anchor": "separate-installation-enablement-and-history" + }, + "configure-preview-and-enable": { + "page": "managed-service", + "anchor": "preview-then-enable-the-intended-behavior" + }, + "reconcile-existing-writers": { + "page": "managed-service", + "anchor": "move-between-actions-and-the-app" + }, + "verify-one-useful-managed-result": { + "page": "managed-service", + "anchor": "verify-a-real-pull-request-result" + }, + "self-hosting": { + "page": "self-host-app", + "anchor": "_top" + }, + "return-the-result": { + "page": "managed-service", + "anchor": "verify-a-real-pull-request-result" + }, + "deeper-reference": { + "page": "managed-app", + "anchor": "_top" + } + } + } + }, + "/docs/github-app/self-hosting/": { + "target": "self-host-app", + "projection": { + "source": "apps/github-app/README.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "69c10e2d54cdbea0c81a67953764db72dbf15331d1ea60f84b0587e56466e20f", + "oldAnchors": [ + "_top", + "meaning", + "boundary", + "local-route", + "migrations-recovery-and-data-handling", + "operator-posture", + "registration-and-credential-helper", + "related-questions" + ], + "anchors": { + "_top": { + "page": "self-host-app", + "anchor": "_top" + }, + "meaning": { + "page": "self-host-app", + "anchor": "_top" + }, + "boundary": { + "page": "self-host-app", + "anchor": "_top" + }, + "local-route": { + "page": "self-host-app", + "anchor": "start-from-a-complete-source-checkout" + }, + "migrations-recovery-and-data-handling": { + "page": "self-host-app", + "anchor": "initialize-and-migrate-the-database" + }, + "operator-posture": { + "page": "self-host-app", + "anchor": "qualify-before-enabling-repositories" + }, + "registration-and-credential-helper": { + "page": "self-host-app", + "anchor": "register-the-intended-github-app" + }, + "related-questions": { + "page": "self-host-app", + "anchor": "related-questions" + } + } + } + }, + "/docs/policies-and-detail/syntax/": { + "target": "detail-language" + }, + "/docs/policies-and-detail/types-and-measurements/": { + "target": "detail-language" + }, + "/docs/policies-and-detail/collections-and-scopes/": { + "target": "detail-language" + }, + "/docs/policies-and-detail/standard-library/": { + "target": "detail-language" + }, + "/docs/policies-and-detail/diagnostics-and-limits/": { + "target": "detail-language" + }, + "/docs/github-app/privacy-and-data/": { + "target": "security-and-data", + "projection": { + "source": "docs/PRIVACY-AND-DATA.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "736d3478e88c0b8a55a77f582800eb7c6549e7076b1acf9853b20978daf4dbb8", + "oldAnchors": [ + "_top", + "meaning", + "three-distinct-lifetimes", + "operational-records-without-history-opt-in", + "persistent-history-is-an-explicit-installation-choice", + "retained-measurement-projection", + "data-excluded-from-analysis-history", + "configuration-is-not-collected-repository-history", + "contextual-detail-stays-on-github", + "statistics-describe-measurements-not-people", + "retention-deletion-and-recovery", + "offboarding-transitions", + "public-playground", + "implementation-evidence", + "related-questions" + ], + "anchors": { + "_top": { + "page": "security-and-data", + "anchor": "_top" + }, + "meaning": { + "page": "security-and-data", + "anchor": "_top" + }, + "three-distinct-lifetimes": { + "page": "security-and-data", + "anchor": "recovery-data-is-not-history-consent" + }, + "operational-records-without-history-opt-in": { + "page": "security-and-data", + "anchor": "recovery-data-is-not-history-consent" + }, + "persistent-history-is-an-explicit-installation-choice": { + "page": "security-and-data", + "anchor": "opt-in-quantitative-history" + }, + "retained-measurement-projection": { + "page": "security-and-data", + "anchor": "opt-in-quantitative-history" + }, + "data-excluded-from-analysis-history": { + "page": "security-and-data", + "anchor": "opt-in-quantitative-history" + }, + "configuration-is-not-collected-repository-history": { + "page": "managed-service", + "anchor": "inspect-the-effective-policy" + }, + "contextual-detail-stays-on-github": { + "page": "security-and-data", + "anchor": "opt-in-quantitative-history" + }, + "statistics-describe-measurements-not-people": { + "page": "security-and-data", + "anchor": "opt-in-quantitative-history" + }, + "retention-deletion-and-recovery": { + "page": "security-and-data", + "anchor": "retention-deletion-and-offboarding" + }, + "offboarding-transitions": { + "page": "security-and-data", + "anchor": "retention-deletion-and-offboarding" + }, + "public-playground": { + "page": "security-and-data", + "anchor": "public-playground" + }, + "implementation-evidence": { + "page": "technical-project-docs", + "anchor": "understand-evidence-and-releases" + }, + "related-questions": { + "page": "security-and-data", + "anchor": "related-questions" + } + } + } + }, + "/docs/security/": { + "target": "security-and-data", + "projection": { + "source": "SECURITY.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "60bb3a32c833d3efc656e2369e78f3db6c5ce90f12e6cf9c3b8ae7e3826b4700", + "oldAnchors": [ + "_top", + "meaning", + "report-privately" + ], + "anchors": { + "_top": { + "page": "security-and-data", + "anchor": "_top" + }, + "meaning": { + "page": "security-and-data", + "anchor": "_top" + }, + "report-privately": { + "page": "security-and-data", + "anchor": "report-a-vulnerability" + } + } + } + }, + "/docs/releases/": { + "target": "releases" + }, + "/docs/": { + "target": "what-is-diffdevil", + "projection": { + "source": "docs/README.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "927b03124b84064fd57ff1450c300d486f99462a1ce722ff011dbdfdb8c5a531", + "oldAnchors": [ + "_top", + "meaning", + "start-with-a-task", + "product-and-repository", + "use-diffdevil", + "detail-language", + "examples", + "dated-evidence", + "related-questions" + ], + "anchors": { + "_top": { + "page": "what-is-diffdevil", + "anchor": "_top" + }, + "meaning": { + "page": "what-is-diffdevil", + "anchor": "_top" + }, + "start-with-a-task": { + "page": "what-is-diffdevil", + "anchor": "get-a-first-result" + }, + "product-and-repository": { + "page": "technical-project-docs", + "anchor": "develop-or-contribute" + }, + "use-diffdevil": { + "page": "surfaces", + "anchor": "_top" + }, + "detail-language": { + "page": "detail-language", + "anchor": "_top" + }, + "examples": { + "page": "try-public-pull-request", + "anchor": "_top" + }, + "dated-evidence": { + "page": "technical-project-docs", + "anchor": "understand-evidence-and-releases" + }, + "related-questions": { + "page": "what-is-diffdevil", + "anchor": "related-questions" + } + } + } + }, + "/docs/setup/actions/": { + "target": "label-pull-requests", + "projection": { + "source": "docs/setup/actions.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "db9b307fbdcb6f24cd80aa70ba8fae298a38d8ee9308083e70d284d6fa7bcc0c", + "oldAnchors": [ + "_top", + "meaning", + "establish-the-target-and-existing-behavior", + "add-or-adapt-the-applying-workflow", + "select-existing-policy-explicitly", + "add-read-only-analysis-where-that-is-the-intended-responsibility", + "activate-and-verify", + "return-the-result", + "deeper-reference" + ], + "anchors": { + "_top": { + "page": "label-pull-requests", + "anchor": "_top" + }, + "meaning": { + "page": "label-pull-requests", + "anchor": "_top" + }, + "establish-the-target-and-existing-behavior": { + "page": "label-pull-requests", + "anchor": "add-the-complete-workflow" + }, + "add-or-adapt-the-applying-workflow": { + "page": "label-pull-requests", + "anchor": "add-the-complete-workflow" + }, + "select-existing-policy-explicitly": { + "page": "label-pull-requests", + "anchor": "customize-only-when-you-have-a-different-question" + }, + "add-read-only-analysis-where-that-is-the-intended-responsibility": { + "page": "github-actions", + "anchor": "_top" + }, + "activate-and-verify": { + "page": "label-pull-requests", + "anchor": "make-it-active-and-inspect-one-result" + }, + "return-the-result": { + "page": "label-pull-requests", + "anchor": "make-it-active-and-inspect-one-result" + }, + "deeper-reference": { + "page": "github-actions-reference", + "anchor": "_top" + } + } + } + }, + "/docs/setup/cli/": { + "target": "analyze-local-changes", + "projection": { + "source": "docs/setup/cli.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "458ff328453a0e6e5c800f6518d8157fa2b0f5156365c2338a8f47156515dad6", + "oldAnchors": [ + "_top", + "meaning", + "read-the-existing-setup", + "obtain-a-usable-cli", + "restricted-or-temporary-harnesses", + "preserve-the-projects-policy", + "verify-a-useful-local-result", + "connect-other-capabilities-only-when-selected", + "return-the-result", + "deeper-reference" + ], + "anchors": { + "_top": { + "page": "analyze-local-changes", + "anchor": "_top" + }, + "meaning": { + "page": "analyze-local-changes", + "anchor": "_top" + }, + "read-the-existing-setup": { + "page": "analyze-local-changes", + "anchor": "install-the-executable" + }, + "obtain-a-usable-cli": { + "page": "analyze-local-changes", + "anchor": "install-the-executable" + }, + "restricted-or-temporary-harnesses": { + "page": "coding-agent", + "anchor": "offline-and-restricted-harnesses" + }, + "preserve-the-projects-policy": { + "page": "configure-policy", + "anchor": "_top" + }, + "verify-a-useful-local-result": { + "page": "analyze-local-changes", + "anchor": "run-the-small-patch" + }, + "connect-other-capabilities-only-when-selected": { + "page": "surfaces", + "anchor": "_top" + }, + "return-the-result": { + "page": "analyze-local-changes", + "anchor": "verify-what-the-report-describes" + }, + "deeper-reference": { + "page": "cli-reference", + "anchor": "_top" + } + } + } + }, + "/docs/setup/everything/": { + "target": "surfaces", + "projection": { + "source": "docs/setup/everything.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "14f3ac7b08aa5f640d532dee7b4383369bacf3f5c663400a536fda94f0ed974f", + "oldAnchors": [ + "_top", + "meaning", + "understand-the-user-and-project", + "choose-the-github-route-from-the-actual-context", + "make-the-cli-available", + "keep-one-deliberate-repository-policy", + "install-the-persistent-skill-at-user-scope", + "complete-the-chosen-github-setup", + "verify-and-return-the-joined-result" + ], + "anchors": { + "_top": { + "page": "surfaces", + "anchor": "_top" + }, + "meaning": { + "page": "surfaces", + "anchor": "_top" + }, + "understand-the-user-and-project": { + "page": "surfaces", + "anchor": "choose-the-job-you-want-to-own" + }, + "choose-the-github-route-from-the-actual-context": { + "page": "surfaces", + "anchor": "combine-readers-freely-choose-writers-deliberately" + }, + "make-the-cli-available": { + "page": "analyze-local-changes", + "anchor": "install-the-executable" + }, + "keep-one-deliberate-repository-policy": { + "page": "configure-policy", + "anchor": "_top" + }, + "install-the-persistent-skill-at-user-scope": { + "page": "coding-agent", + "anchor": "install-the-complete-skill-not-a-pasted-introduction" + }, + "complete-the-chosen-github-setup": { + "page": "surfaces", + "anchor": "combine-readers-freely-choose-writers-deliberately" + }, + "verify-and-return-the-joined-result": { + "page": "surfaces", + "anchor": "combine-readers-freely-choose-writers-deliberately" + } + } + } + }, + "/docs/browser-extension/": { + "target": "browser-extension", + "projection": { + "source": "apps/browser-extension/README.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "5e6e6507e9f928635cb9aece39d2d2d89fc7d3fec6fc65b6d460df1d56c3ee94", + "oldAnchors": [ + "_top", + "build-and-load", + "what-appears-on-a-pull-request", + "your-settings", + "policy-and-labels", + "local-and-app-standing", + "data-and-permissions", + "verification-and-distribution", + "related-questions" + ], + "anchors": { + "_top": { + "page": "browser-extension", + "anchor": "_top" + }, + "build-and-load": { + "page": "browser-extension", + "anchor": "install-and-update" + }, + "what-appears-on-a-pull-request": { + "page": "browser-extension", + "anchor": "read-the-pull-request-view" + }, + "your-settings": { + "page": "browser-extension", + "anchor": "find-and-change-a-setting" + }, + "policy-and-labels": { + "page": "browser-extension", + "anchor": "choose-which-policy-you-are-seeing" + }, + "local-and-app-standing": { + "page": "browser-extension", + "anchor": "interpret-a-virtual-band-correctly" + }, + "data-and-permissions": { + "page": "browser-extension", + "anchor": "permissions-and-limitations" + }, + "verification-and-distribution": { + "page": "browser-extension", + "anchor": "install-and-update" + }, + "related-questions": { + "page": "browser-extension", + "anchor": "related-questions" + } + } + } + }, + "/docs/browser-extension/integration/": { + "target": "browser-extension", + "projection": { + "source": "docs/integration/browser-extension.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "8f4921936dc9941dd72324e210fd8c4357e602aa9d219bfb607572a52518d998", + "oldAnchors": [ + "_top", + "meaning", + "public-entry-point", + "policy-and-projection", + "build-licence-and-verification", + "related-questions" + ], + "anchors": { + "_top": { + "page": "browser-extension", + "anchor": "_top" + }, + "meaning": { + "page": "browser-extension", + "anchor": "_top" + }, + "public-entry-point": { + "page": "browser-extension", + "anchor": "install-and-update" + }, + "policy-and-projection": { + "page": "browser-extension", + "anchor": "choose-which-policy-you-are-seeing" + }, + "build-licence-and-verification": { + "page": "browser-extension", + "anchor": "permissions-and-limitations" + }, + "related-questions": { + "page": "browser-extension", + "anchor": "related-questions" + } + } + } + }, + "/docs/agents/agent-integration/": { + "target": "coding-agent" + }, + "/docs/agents/skill/": { + "target": "coding-agent", + "projection": { + "source": "skills/diffdevil/SKILL.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "1b66467dd21a50559abd3ec8fcc22c17c7249fb4a4f662e7662738e74df1b924", + "oldAnchors": [ + "_top", + "diffdevil", + "check-the-skill-version", + "put-measured-changes-to-work", + "select-the-comparison", + "read-and-use-results", + "work-with-policy-and-effects", + "go-deeper-at-the-relevant-operation" + ], + "anchors": { + "_top": { + "page": "coding-agent", + "anchor": "_top" + }, + "diffdevil": { + "page": "coding-agent", + "anchor": "_top" + }, + "check-the-skill-version": { + "page": "coding-agent", + "anchor": "update-without-breaking-a-working-installation" + }, + "put-measured-changes-to-work": { + "page": "coding-agent", + "anchor": "use-it-in-ordinary-work" + }, + "select-the-comparison": { + "page": "analyze-local-changes", + "anchor": "verify-what-the-report-describes" + }, + "read-and-use-results": { + "page": "coding-agent", + "anchor": "choose-output-and-effect-authority" + }, + "work-with-policy-and-effects": { + "page": "coding-agent", + "anchor": "choose-output-and-effect-authority" + }, + "go-deeper-at-the-relevant-operation": { + "page": "coding-agent", + "anchor": "use-it-in-ordinary-work" + } + } + } + }, + "/docs/agents/setup/": { + "target": "coding-agent" + }, + "/docs/setup/skill/": { + "target": "coding-agent", + "projection": { + "source": "docs/setup/skill.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "03f323bf2c93e589f12842a957fdeec811425ae8e2cb333abb52fc9a6f0be171", + "oldAnchors": [ + "_top", + "related-questions" + ], + "anchors": { + "_top": { + "page": "coding-agent", + "anchor": "_top" + }, + "related-questions": { + "page": "coding-agent", + "anchor": "related-questions" + } + } + } + }, + "/docs/actions/github-api/": { + "target": "source-identity-trust-and-mutation", + "projection": { + "source": "docs/integration/github-api.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "eb5fc40ccdf890a78158ecc3ed0db7232c85ee9e31fee16579e0b18dab14ab61", + "oldAnchors": [ + "_top", + "meaning", + "read-pull-request-evidence", + "select-trusted-policy-separately", + "apply-explicitly-and-inspect-the-complete-result", + "labels-and-definitions", + "owned-comments", + "qualification-boundary", + "related-questions" + ], + "anchors": { + "_top": { + "page": "source-identity-trust-and-mutation", + "anchor": "_top" + }, + "meaning": { + "page": "source-identity-trust-and-mutation", + "anchor": "_top" + }, + "read-pull-request-evidence": { + "page": "source-identity-trust-and-mutation", + "anchor": "identify-the-comparison-not-just-the-repository" + }, + "select-trusted-policy-separately": { + "page": "source-identity-trust-and-mutation", + "anchor": "treat-proposed-changes-as-data-not-trusted-policy" + }, + "apply-explicitly-and-inspect-the-complete-result": { + "page": "reports-plans-and-apply", + "anchor": "_top" + }, + "labels-and-definitions": { + "page": "labels-comments-definitions", + "anchor": "_top" + }, + "owned-comments": { + "page": "labels-comments-definitions", + "anchor": "_top" + }, + "qualification-boundary": { + "page": "technical-project-docs", + "anchor": "understand-evidence-and-releases" + }, + "related-questions": { + "page": "source-identity-trust-and-mutation", + "anchor": "related-questions" + } + } + } + }, + "/docs/cli/presentation/": { + "target": "cli-reference", + "projection": { + "source": "docs/presentation.md", + "fromRef": "9e415435321382ab6eb7966ec6d7f4d7dc5598dd", + "fromSourceSha256": "5e96a60bbf00e94141f328ba41ad42985160582e599e21d241cd44da2a8e245a", + "oldAnchors": [ + "_top", + "meaning", + "one-semantic-result-several-reading-surfaces", + "human-report-summary", + "human-full-aggregate-detail", + "evidence-vocabulary", + "human-effect-plan-summary", + "terminal-colour", + "agent-report-projection", + "agent-plan-projection", + "presentation-versioning", + "consumer-contract", + "settled-interface-decisions" + ], + "anchors": { + "_top": { + "page": "cli-reference", + "anchor": "_top" + }, + "meaning": { + "page": "cli-reference", + "anchor": "_top" + }, + "one-semantic-result-several-reading-surfaces": { + "page": "interfaces", + "anchor": "_top" + }, + "human-report-summary": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "human-full-aggregate-detail": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "evidence-vocabulary": { + "page": "evidence-and-uncertainty", + "anchor": "_top" + }, + "human-effect-plan-summary": { + "page": "reports-plans-and-apply", + "anchor": "_top" + }, + "terminal-colour": { + "page": "cli-reference", + "anchor": "common-options-and-compatibility" + }, + "agent-report-projection": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "agent-plan-projection": { + "page": "cli-reference", + "anchor": "output-formats-and-stream-guarantees" + }, + "presentation-versioning": { + "page": "schemas-and-compatibility", + "anchor": "_top" + }, + "consumer-contract": { + "page": "interfaces", + "anchor": "_top" + }, + "settled-interface-decisions": { + "page": "technical-project-docs", + "anchor": "integrate-or-embed" + } + } + } + } + } +} diff --git a/apps/manual/capabilities.json b/apps/manual/capabilities.json new file mode 100644 index 00000000..1caea776 --- /dev/null +++ b/apps/manual/capabilities.json @@ -0,0 +1,59 @@ +{ + "extension-admission": { + "state": "source-admitted", + "source": "apps/browser-extension/README.md", + "admittedAt": "b1bd2f7212874039a413216cf73edc5192962a83", + "remaining": "Initial extension source is admitted; corrected functional PR #45 at 59f67c32db06db4a7bf8e3002866d600732f6789 is a separate open candidate. Signed-in aggregate/per-file/report witness, current-main integration, final in-place co-design and Store publication are not established." + }, + "store-coordinate": { + "state": "unobserved", + "source": "apps/browser-extension/README.md", + "remaining": "Published and verified Chrome Web Store URL." + }, + "dashboard-controls": { + "state": "active-owner-co-design", + "source": "docs/integration/github-app.md", + "remaining": "Final wording, labels, composition, user routes, controls, interaction sequence and complete first journey remain Wolf + Nyxara co-design. Backend functionality does not select them. The corrected non-visual authorization/session PR #54 at 2a696ad0e5fd97db3b3f324a42881feaf92c55c2 remains an open candidate, not the deployed dashboard; session lifetime and retained consent-actor identity remain owner choices." + }, + "official-app-coordinate": { + "state": "configured-not-public", + "source": "apps/website/src/data/site.ts", + "remaining": "Registration/installation/backend coordinates do not establish a public authenticated installation or dashboard journey. Public release and current route readback are separate." + }, + "live-service-qualification": { + "state": "contained-backend-canary", + "source": "docs/DIRECTION.md", + "remaining": "The admission/settings backend was deployed and a contained canary was observed. One separate repository admission has execution on and history off; an ordinary configured PR result is still unobserved. Public HTTP ingress, authenticated administration, wider recovery/history/export/deletion/restore and public release remain separate." + }, + "operator-runbook": { + "state": "source-available-partial-qualification", + "source": "apps/github-app/README.md", + "remaining": "Worker/Queue/D1, migrations, fenced recovery, admission use cases and credential helper are source-owned. Full production lifecycle, authenticated adapter, backup restoration and deletion readback remain separate." + }, + "unsupported-docker-route": { + "state": "not-selected", + "source": "docs/integration/github-app.md", + "remaining": "No Docker instructions until that adapter is selected and implemented." + }, + "partial-size-maps": { + "state": "requires-source", + "source": "src/diffdevil/contracts/detail/v1/shortcuts.json", + "remaining": "Compiler/schema support and executed partial threshold/label-map examples." + }, + "stable-skill-carrier": { + "state": "publication-unobserved", + "source": "skills/diffdevil/SKILL.md", + "remaining": "Published manifest/assets and actual stable-install readback." + }, + "commercial-terms": { + "state": "requires-authority", + "source": "docs/integration/github-app.md", + "remaining": "Free 30-day and paid no-age-expiry quantitative-history tiers are selected. Prices, quotas, billing journey and service commitments remain undefined; do not invent them." + }, + "faq-candidate-admission": { + "state": "source-admitted", + "source": "docs/manual/faq.md", + "admittedAt": "ed795c97b3052441f02f899876122f1967f1f1e2", + "remaining": "Preserve its answers and qualify only the shared foundation interfaces." + } +} diff --git a/apps/manual/foundation.test.mjs b/apps/manual/foundation.test.mjs new file mode 100644 index 00000000..add8e2d6 --- /dev/null +++ b/apps/manual/foundation.test.mjs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { pages, manualPages, byKey, sidebar, pageUrl, validateManifest, packageSource } from './manifest.mjs'; +import { migrations, retainedFamilies, pageIsCurrent, validateTransfer, legacyRedirects, validateRetiredRoutes } from './migration.mjs'; +import { sourceTargets, resolveSource } from './source-resolver.mjs'; +import { themeScript } from '../website/src/lib/theme-script.mjs'; +import { themeTransferUrl } from '../website/src/lib/theme-transfer.mjs'; +import { searchKind } from '../website/src/lib/search-kinds.mjs'; +const state = JSON.parse(readFileSync(new URL('./authoring-state.json',import.meta.url),'utf8')); +const ref = '1'.repeat(40); + +test('explicit sources, routes and the separate FAQ form one unambiguous selection',()=>{ + assert.equal(validateManifest(),true); + assert.equal(new Set(pages.map(page=>page.key)).size,pages.length); + assert.equal(pages.filter(page=>page.kind==='FAQ').length,1); + assert.equal(pageUrl('faq'),'https://diffdevil.dev/faq/'); + assert.equal(byKey.get('faq').source,'docs/manual/faq.md'); + assert.equal(pageUrl('what-is-diffdevil'),'https://docs.diffdevil.dev/'); + assert.deepEqual(byKey.get('what-is-diffdevil').aliases,['/start/what-is-diffdevil/']); + assert.equal(pageUrl('effects-and-templates'),'https://docs.diffdevil.dev/policy/effects-and-templates/'); + assert.notEqual(byKey.get('effects-and-templates').source.replace('docs/manual','').replace('.md','/'),byKey.get('effects-and-templates').route); + assert.throws(()=>validateManifest([...pages,pages[0]]),/Duplicate/u); +}); +test('linked category pages keep separate disclosure defaults and real route slugs',()=>{ + const all=[]; const collect=items=>{for(const item of items){all.push(item);collect(item.items??[]);}}; collect(sidebar({includeFAQ:true})); + for(const key of ['surfaces','managed-app','shared-workflows','interfaces','language-and-contracts']){ + const page=byKey.get(key),group=all.find(item=>item.label===page.title); + assert.ok(group?.items.length); assert.equal(group.slug,page.route.slice(1,-1)); assert.equal(group.defaultOpen,page.defaultOpen); + } + assert.equal(all.find(item=>item.label==='REFERENCE').defaultOpen,false); + assert.equal(all.find(item=>item.label==='FAQ').link,pageUrl('faq')); +}); +test('scaffolds are explicit and capability standing is not authoring readiness',()=>{ + for(const page of manualPages){ + const body=readFileSync(new URL('../../'+page.source,import.meta.url),'utf8'); + assert.ok(body.startsWith('# '+page.title+'\n')); + assert.ok(['scaffold','authored'].includes(state.pages[page.key].status)); + const notes=[...body.matchAll(/^> \[!NOTE\]\r?\n> \*\*In development\*\*/gmu)]; + assert.equal(notes.length,page.availability==='in-development'?1:0); + if(state.pages[page.key].status==='scaffold') assert.equal(pageIsCurrent(page.key,state),false); + } + assert.equal(packageSource.commit,'22d4567006ec7a33d890fab2f3d3515332498a90'); + assert.ok(retainedFamilies.some(row=>row.sources.includes('skills/diffdevil/**'))); +}); +test('resolver succeeds only for finite present source IDs and exact provenance',()=>{ + const targets=sourceTargets({ref,state,exists:path=>path==='docs/manual/README.md'||path==='SECURITY.md'}); + assert.equal(resolveSource('/source/?f=docs%2Fmanual%2FREADME.md#part',targets),`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/docs/manual/README.md#part`); + for(const id of ['../secret','https://evil.invalid/','__proto__','constructor','docs/manual/missing.md']) assert.equal(resolveSource('/source/?f='+encodeURIComponent(id),targets),null); + assert.equal(resolveSource('/source/?f=SECURITY.md&f=README.md',targets),null); + assert.equal(resolveSource('/source/',targets),null); + assert.throws(()=>sourceTargets({ref:'main',state,exists:()=>true}),/exact commit/u); +}); +test('split migrations cannot transfer only the convenient successor',()=>{ + const row=migrations.find(row=>row.source==='docs/guides/auto-label-pull-requests.md'); + const local={pages:{'label-pull-requests':{status:'authored'},'github-actions':{status:'scaffold'}},transfers:{}}; + const transfer={phase:'retired',primary:row.primary,fromSourceSha256:'a'.repeat(64),oldAnchors:['old'],anchors:{old:{page:row.primary,anchor:'new'}},redirects:true,sourceIds:true,search:true}; + const observed={sourceExists:false,inboundLinks:[],targetAnchors:{[row.primary]:['new']}}; + assert.throws(()=>validateTransfer(row,transfer,local,observed),/successor not authored/u); + local.pages['github-actions'].status='authored'; + assert.doesNotThrow(()=>validateTransfer(row,transfer,local,observed)); + assert.throws(()=>validateTransfer(row,transfer,local,{...observed,sourceExists:true}),/forwarding Markdown/u); + assert.throws(()=>validateTransfer(row,transfer,local,{...observed,inboundLinks:['README.md']}),/inbound links/u); + assert.throws(()=>validateTransfer(row,transfer,local,{...observed,targetAnchors:{}}),/successor anchor/u); + assert.throws(()=>legacyRedirects({pages:{},routes:{'/docs/cli/':{target:'cli'}}}),/Premature/u); + assert.throws(()=>legacyRedirects({routes:{'/unknown/':{target:'cli'}}}),/Unknown legacy/u); +}); +test('source-ID fragments can move to a different successor without accepting arbitrary paths',()=>{ + const local={pages:{},transfers:{'docs/integration/cli.md':{phase:'retired',primary:'cli',anchors:{output:{page:'cli-reference',anchor:'formats'}}}}}; + const targets=sourceTargets({ref,state:local,exists:()=>true}); + assert.equal(resolveSource('/source/?f=docs/integration/cli.md#output',targets),`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/docs/manual/reference/cli.md#formats`); +}); +function initial({url='https://diffdevil.dev/',stored={},light=false}={}){ + const attrs={},storage=new Map(Object.entries(stored)),history={state:null,replaceState(_s,_t,path){this.path=path;}}; + vm.runInNewContext(themeScript,{document:{addEventListener(){},documentElement:{setAttribute:(k,v)=>attrs[k]=v}},localStorage:{getItem:k=>storage.get(k)??null,setItem:(k,v)=>storage.set(k,v)},URL,location:{href:url},history,window:{matchMedia:()=>({matches:light})}}); + return {attrs,storage,history}; +} +test('theme stays dark-first and preserves Auto/bounce before first paint',()=>{ + assert.equal(initial({light:true}).attrs['data-theme'],'dark'); + const auto=initial({stored:{'diffdevil.theme':'system'},light:true}); + assert.equal(auto.attrs['data-theme'],'light'); assert.equal(auto.attrs['data-theme-pref'],'auto'); assert.equal(auto.attrs['data-theme-next'],'dark'); +}); +test('cross-host handoff is finite, preserves query/fragment, and does not invent shared storage',()=>{ + const url=themeTransferUrl('https://docs.diffdevil.dev/use/?q=a#heading','https://diffdevil.dev/','auto','light'); + assert.equal(new URL(url).searchParams.get('dd-theme'),'auto.light'); + const received=initial({url,light:false}); + assert.equal(received.attrs['data-theme-pref'],'auto'); assert.equal(received.attrs['data-theme-next'],'light'); assert.equal(received.history.path,'/use/?q=a#heading'); + assert.equal(themeTransferUrl('https://app.diffdevil.dev/','https://diffdevil.dev/','light','dark'),'https://app.diffdevil.dev/'); + assert.equal(themeTransferUrl('/faq/','https://diffdevil.dev/','light','dark'),'https://diffdevil.dev/faq/'); + assert.equal(initial({url:'https://docs.diffdevil.dev/?dd-theme=evil.dark'}).attrs['data-theme'],'dark'); + assert.equal(initial({url:'https://docs.diffdevil.dev/?dd-theme=light.dark&dd-theme=dark.light'}).attrs['data-theme'],'dark'); +}); +test('search kind is explicit metadata, independent of source layout or collapsed navigation',()=>{ + assert.equal(searchKind('https://docs.diffdevil.dev/reference/cli/'),'DOCS'); + assert.equal(searchKind('/faq/#changed-lines',{kind:'FAQ'}),'FAQ'); + assert.equal(searchKind('/anything/',{kind:'DOCS'}),'DOCS'); + assert.equal(searchKind('/playground/'),'SITE'); +}); + + +// The same handler is emitted into both actual static outputs. +import { createStaticHandler } from './static-handler.mjs'; +test('Matching-host 308 aliases preserve query and unrelated requests reach static assets',async()=>{ + const origins={site:'https://diffdevil.dev',docs:'https://docs.diffdevil.dev',compatibility:'https://www.diffdevil.dev'}; + const seen=[]; + const env={ASSETS:{fetch:async request=>{seen.push(request.url);return new Response('asset');}}}; + const site=createStaticHandler({...origins,host:'site',redirects:[]}); + const docs=createStaticHandler({...origins,host:'docs',redirects:[{from:'/start/what-is-diffdevil/',to:origins.docs+'/'}]}); + for(const method of ['GET','POST']) { + const response=await site.fetch(new Request(origins.compatibility+'/faq/?q=hello%20world',{method}),env); + assert.equal(response.status,308); + assert.equal(response.headers.get('location'),origins.site+'/faq/?q=hello%20world'); + } + const alias=await docs.fetch(new Request(origins.docs+'/start/what-is-diffdevil/?keep=1'),env); + assert.equal(alias.status,308);assert.equal(alias.headers.get('location'),origins.docs+'/?keep=1'); + for(const url of [origins.docs+'/unknown/?f=https://evil.invalid',origins.docs+'/assets/site.css',origins.site+'/start/what-is-diffdevil/']) { + const response=await docs.fetch(new Request(url),env);assert.equal(await response.text(),'asset'); + } + assert.equal(seen.length,3); +}); + +test('A source retirement cannot silently leave its public route behind',()=>{ + const local={transfers:{'old.md':{phase:'retired'}}}; + const entries=[{source:'old.md',slug:'old'}]; + assert.throws(()=>validateRetiredRoutes(local,entries,[]),/missing legacy route/u); + assert.doesNotThrow(()=>validateRetiredRoutes(local,entries,[{from:'/docs/old/',to:pageUrl('cli'),status:308}])); +}); diff --git a/apps/manual/fragment-destination.mjs b/apps/manual/fragment-destination.mjs new file mode 100644 index 00000000..2e0de4fa --- /dev/null +++ b/apps/manual/fragment-destination.mjs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Follow an explicitly mapped old fragment without losing the caller's query. + * Returning null means no navigation, including an already-current destination. + */ +export function fragmentDestination(current, aliases) { + let url, id; + try { url = new URL(current); id = decodeURIComponent(url.hash.slice(1)); } catch { return null; } + if (!Object.hasOwn(aliases,id)) return null; + const target = new URL(aliases[id]); + target.search = url.search; + return target.href === url.href ? null : target.href; +} diff --git a/apps/manual/generated-content.mjs b/apps/manual/generated-content.mjs new file mode 100644 index 00000000..430ee4e5 --- /dev/null +++ b/apps/manual/generated-content.mjs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Check committed mechanical islands; narrative outside the markers is never rewritten. */ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generatedIsland } from './generated-islands.mjs'; +import { manualPages } from './manifest.mjs'; + +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const paired = /\n([\s\S]*?)\n/gu; + +export function committedIslands(raw, { root, source, write = false, receipts = [] }) { + const seen = new Set(); + const result = raw.replace(paired, (whole, id, body) => { + if (seen.has(id)) throw new Error(`${source}: duplicate generated island ${id}`); + seen.add(id); + const sources = new Set(); + const expected = generatedIsland(id, { root, source, onSource: path => sources.add(path) }); + if (!write && body !== expected) throw new Error(`${source}: generated ${id} drift; run npm --prefix apps/manual run generate`); + receipts.push({ source, island: id, sha256: hash(expected), bytes: Buffer.byteLength(expected), inputs: [...sources].sort().map(path => ({path, sha256: hash(readFileSync(join(root,path)))})) }); + return `\n${expected}\n`; + }); + const closed = [...raw.matchAll(//gu)]; + const openings = [...raw.matchAll(//gu)]; + if (manualPages.some(page => page.source === source && page.wave >= 3) && openings.length !== seen.size) throw new Error(`${source}: missing committed generated edit boundary`); + if (closed.length !== seen.size) throw new Error(`${source}: malformed generated edit boundary`); + return result; +} + +/** Preserve the earlier waves' dynamic presenter islands; paired inventories are committed. */ +export function projectIslands(raw, options) { + const checked = committedIslands(raw, options); + const islands = []; + const masked = checked.replace(paired, (_, id, body) => { islands.push(body); return `\u0000MANUAL_ISLAND_${islands.length-1}\u0000`; }); + const expanded = masked.replace(//gu, (_,id) => generatedIsland(id, options)); + return expanded.replace(/\u0000MANUAL_ISLAND_(\d+)\u0000/gu, (_,index) => islands[Number(index)]); +} + +export function updateInventories({root, write = false}) { + const receipts = []; + for (const page of manualPages) { + const path = join(root,page.source), raw = readFileSync(path,'utf8'); + const updated = committedIslands(raw,{root,source:page.source,write,receipts}); + if (write && updated !== raw) writeFileSync(path,updated); + } + return receipts; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = fileURLToPath(new URL('../../',import.meta.url)); + const args = process.argv.slice(2); + if (args.some(arg => !['--write','--check'].includes(arg)) || args.includes('--write') && args.includes('--check')) throw new Error('Use --write or --check.'); + const receipts = updateInventories({root,write:args.includes('--write')}); + console.log(`manual: ${receipts.length} committed inventories ${args.includes('--write')?'updated':'checked'}`); +} diff --git a/apps/manual/generated-islands.mjs b/apps/manual/generated-islands.mjs new file mode 100644 index 00000000..32690fae --- /dev/null +++ b/apps/manual/generated-islands.mjs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Mechanical inventories are generated inside a maintained Markdown chapter. */ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, relative, posix } from 'node:path'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { sourceUrl } from './source-resolver.mjs'; +const cell=value=>String(value ?? '').replaceAll('|','\\|').replace(/\r?\n/gu,' ').trim(); +function code(value) { + const text=String(value ?? '').replace(/\r?\n/gu,' '); + const length=Math.max(0,...[...text.matchAll(/`+/gu)].map(match=>match[0].length))+1; + const delimiter='`'.repeat(length), pad=text.startsWith('`')||text.endsWith('`')?' ':''; + return delimiter+pad+text+pad+delimiter; +} +function table(headers,rows) { return ['| '+headers.join(' | ')+' |','| '+headers.map(()=>'---').join(' | ')+' |',...rows.map(row=>'| '+row.map(cell).join(' | ')+' |')].join('\n'); } +function files(root,base) { + const result=[]; + for(const entry of readdirSync(join(root,base),{withFileTypes:true})) { + const path=base+'/'+entry.name; + if(entry.isDirectory()) result.push(...files(root,path)); + else if(entry.isFile()) result.push(path); + } + return result.sort(); +} +/** Enumerate structural branches without promoting branch-local requirements to globals. + * References stay references; definitions retain their own schema pointers. */ +export function schemaFields(schema, path = '$', required = false, pointer = '#') { + const rows = []; + if (typeof schema === 'boolean') return [[code(path), code(schema), required ? 'Required in this object/branch' : 'Not required here', '', code(pointer), 'Boolean schema']]; + if (!schema || typeof schema !== 'object') return rows; + const type = schema.type ?? (schema.properties ? 'object' : schema.$ref ? 'reference' : 'unspecified'); + const nested = new Set(['properties','patternProperties','$defs','definitions','items','prefixItems','additionalProperties','unevaluatedProperties','contains','propertyNames','oneOf','anyOf','allOf','not','if','then','else','dependentSchemas']); + const constraints = Object.fromEntries(Object.entries(schema).filter(([key]) => !nested.has(key) && !['type','description','title','$comment','$schema','$id'].includes(key))); + rows.push([code(path), Array.isArray(type) ? type.map(code).join(' | ') : code(type), required ? 'Required in this object/branch' : 'Not required here', schema.description ?? '', code(pointer), code(JSON.stringify(constraints))]); + const escape = value => value.replaceAll('~','~0').replaceAll('/','~1'); + for (const [name, child] of Object.entries(schema.properties ?? {})) rows.push(...schemaFields(child, path + '.' + name, schema.required?.includes(name), pointer + '/properties/' + escape(name))); + for (const keyword of ['patternProperties','$defs','definitions','dependentSchemas']) + for (const [name, child] of Object.entries(schema[keyword] ?? {})) rows.push(...schemaFields(child, path + '.' + keyword + '[' + JSON.stringify(name) + ']', false, pointer + '/' + keyword + '/' + escape(name))); + for (const keyword of ['items','additionalProperties','unevaluatedProperties','contains','propertyNames','not','if','then','else']) + if (schema[keyword] !== undefined && !Array.isArray(schema[keyword])) rows.push(...schemaFields(schema[keyword], path + (keyword === 'items' ? '[]' : '.' + keyword), false, pointer + '/' + keyword)); + for (const keyword of ['oneOf','anyOf','allOf','prefixItems']) + for (const [index, child] of (schema[keyword] ?? []).entries()) rows.push(...schemaFields(child, path + '.' + keyword + '[' + index + ']', false, pointer + '/' + keyword + '/' + index)); + return rows; +} +/** Public aliases, overloads and type declarations come from the built package exports. */ +function publicApi(root, pkg, link, recordSource) { + // The root compiler is TypeScript 7; its CLI is not the compiler API. Use + // the already-pinned API alias on both root-only and manual-toolchain installs. + const ts = createRequire(join(root,'package.json'))('typescript-6'); + const entries = Object.entries(pkg.exports).filter(([,value]) => value && typeof value === 'object' && typeof value.types === 'string'); + const program = ts.createProgram(entries.map(([,value]) => join(root,value.types)), {skipLibCheck:true,module:ts.ModuleKind.NodeNext,moduleResolution:ts.ModuleResolutionKind.NodeNext,target:ts.ScriptTarget.ES2022}); + const checker = program.getTypeChecker(), printer = ts.createPrinter({removeComments:true,newLine:ts.NewLineKind.LineFeed}); + const declarations = new Map(), indexes = []; + for (const [subpath,value] of entries) { + recordSource(value.types.replace(/^\.\//u,'')); + const file = program.getSourceFile(join(root,value.types)); + const module = file && checker.getSymbolAtLocation(file); + if (!module) throw new Error(`Public declaration was not built: ${value.types}`); + const rows = checker.getExportsOfModule(module).sort((a,b)=>a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map(item => { + const target = item.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(item) : item; + const nodes = target.getDeclarations() ?? []; + if (!nodes.length) throw new Error(`Missing declaration for ${subpath}/${item.name}`); + const declaredPath = relative(root,nodes[0].getSourceFile().fileName).replaceAll('\\','/'); + const id = 'api-' + (declaredPath.replace(/^dist\/lib\//u,'').replace(/\.d\.ts$/u,'') + '-' + target.name).replace(/[^A-Za-z0-9_-]/gu,'-').toLowerCase(); + if (!declarations.has(id)) { + const bodies = nodes.map(node => { + recordSource(relative(root,node.getSourceFile().fileName).replaceAll('\\','/')); + const printed = printer.printNode(ts.EmitHint.Unspecified,node,node.getSourceFile()).trim(); + return ts.isVariableDeclaration(node) ? 'declare const ' + printed + ';' : printed; + }); + const source = declaredPath.replace(/^dist\/lib\//u,'src/diffdevil/').replace(/\.d\.ts$/u,'.ts'); + const external = declaredPath.startsWith('node_modules/'); + if (!external && !existsSync(join(root,source))) throw new Error(`No canonical source for ${declaredPath}`); + if (!external) recordSource(source); + declarations.set(id, `\n\n#### ${code(target.name)}\n\n${external ? code(declaredPath) + ' · ' + link('Dependency identity', 'package-lock.json') : link('Canonical source',source)}\n\n` + '```typescript\n' + [...new Set(bodies)].join('\n') + '\n```'); + } + return [code(item.name),code(target.name),`[Declaration](#${id})`]; + }); + indexes.push(`### ${subpath === '.' ? pkg.name : pkg.name + subpath.slice(1)}\n\n${table(['Public export','Declared symbol','Signature / type'],rows)}`); + } + return indexes.join('\n\n') + '\n\n### Public declarations\n\n' + [...declarations.entries()].sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0).map(([,body])=>body).join('\n\n'); +} +/** Run only the explicit, read-only specimens selected below. */ +function cli(root, entry, args, expected = 0) { + const result = spawnSync(process.execPath,[join(root,entry),...args],{cwd:root,encoding:'utf8',env:{...process.env,NO_COLOR:'1',FORCE_COLOR:'0'},shell:false,windowsHide:true}); + if (result.error || result.status !== expected) throw new Error(`Manual specimen ${args.join(' ')} failed: ${result.error?.message ?? result.stderr}`); + return result.stdout.replace(/\r\n/gu,'\n').trimEnd(); +} +export function generatedIsland(id,{root,ref,source,onSource = () => {}}) { + const recordSource = path => { onSource(path); }; + const read = path => { recordSource(path); return readFileSync(join(root,path),'utf8'); }; + const pkg=JSON.parse(read('package.json')); + const require=createRequire(join(root,'package.json')); + const entry=typeof pkg.bin==='string'?pkg.bin:pkg.bin.diffdevil; + const run=args=>cli(root,entry,args); + const link=(label,path)=>{ recordSource(path); const url = source ? posix.relative(posix.dirname(source),path) : sourceUrl(path,ref); return `[${label}](${url})`; }; + if(id==='app-execution-stages') { + const path='apps/github-app/app.mjs'; + const stages=new Map(); + for (const [,fallback,phase] of read(path).matchAll(/atExecutionStage\('([A-Z0-9_]+)', '([a-z-]+)'/gu)) { + if(stages.has(fallback) && stages.get(fallback)!==phase) throw new Error(`Ambiguous execution stage: ${fallback}`); + stages.set(fallback,phase); + } + if(!stages.size) throw new Error('The App execution-stage inventory is empty.'); + return link('Canonical execution boundary',path)+'\n\n'+table(['Fallback diagnostic','Execution phase'],[...stages].map(([fallback,phase])=>[code(fallback),code(phase)])); + } + if(id==='release-source-identities') { + const {parse}=require('yaml'); + const skillPath='skills/diffdevil/SKILL.md', skillText=read(skillPath); + const front=skillText.match(/^---\n([\s\S]*?)\n---/u); + if(!front) throw new Error('The canonical Skill has no metadata.'); + const skill=parse(front[1]); + if(typeof skill.metadata?.version!=='string') throw new Error('The Skill version is absent.'); + const rows=[ + ['Package source version',code(pkg.version),link('Package metadata','package.json')], + ['Package Node.js floor',code(pkg.engines.node),link('Runtime requirement','package.json')], + ['Skill source version',code(skill.metadata.version),link('Complete Skill source',skillPath)], + ]; + for(const path of ['action.yml','actions/analyze/action.yml','actions/apply/action.yml','actions/sync-labels/action.yml']) { + const action=parse(read(path)); + if(!action.runs?.using) throw new Error(`Action runtime missing: ${path}`); + rows.push([path,code(action.runs.using),link('Action runtime declaration',path)]); + } + return table(['Source identity','Declared value','Canonical metadata'],rows); + } + if(id==='actions') { + const {parse}=require('yaml'); + return ['action.yml','actions/analyze/action.yml','actions/apply/action.yml','actions/sync-labels/action.yml'].map(path=>{ + const action=parse(read(path)); + const inputs=Object.entries(action.inputs ?? {}).map(([name,value])=>[code(name),value.required===true?'Yes':'No',value.default === undefined ? 'Not declared' : code(JSON.stringify(value.default)),value.description]); + const outputs=Object.entries(action.outputs ?? {}).map(([name,value])=>[code(name),value.description]); + return `### ${path}\n\n${link('Canonical Action metadata',path)}\n\n${table(['Input','Required','Default','Description'],inputs)}\n\n${table(['Output','Description'],outputs)}`; + }).join('\n\n'); + } + if(id==='cli-help') { + const output=run(['--help']); + return '```text\n'+output.trimEnd()+'\n```'; + } + if(id==='typescript-exports') return publicApi(root,pkg,link,recordSource); + if(id==='real-pr-catalogue') { + const catalogue=JSON.parse(read('docs/examples/catalogue/catalogue.json')); + if (!Array.isArray(catalogue.entries) || !catalogue.entries.length) throw new Error('The shared PR catalogue is empty.'); + return table(['Example','Lessons','Inspect'],catalogue.entries.map(entry=>{ + const variant=entry.variants?.[0]; + if (!variant) throw new Error(`No catalogue variant for ${entry.id}`); + return [entry.title,entry.lessons.join(', '),`[Open in the playground](https://diffdevil.dev/playground/?example=${encodeURIComponent(entry.id)}&variant=${encodeURIComponent(variant.id)})`]; + })); + } + if(id==='schemas') { + const paths=files(root,'src/diffdevil/contracts/schemas').filter(path=>path.endsWith('.json')); + if(!paths.length) throw new Error('The canonical schema inventory is empty.'); + return paths.map(path=>{ + const schema=JSON.parse(read(path)); + return `### ${path.split('/').at(-1)}\n\n${link('Canonical JSON Schema',path)}\n\n${table(['Field / branch','Type','Requiredness','Description','Schema pointer','Constraints'],schemaFields(schema))}`; + }).join('\n\n'); + } + if(id==='presets') { + const {parse,stringify}=require('yaml'); + const path='src/diffdevil/presets/size-v1.yml'; + const expansion=JSON.parse(run(['explain','--policy','--preset','size@1','--no-config','--format','json'])); + assert.deepEqual(expansion.document,parse(read(path)), 'size@1 expansion drifted from its canonical source'); + return `${link('size@1 source',path)}\n\n` + '```yaml\n' + stringify(expansion.document).trimEnd() + '\n```'; + } + if (['detail-operators','detail-functions','detail-diagnostics','detail-limits'].includes(id)) { + const section = id.slice('detail-'.length); + const all = generatedIsland('detail',{root,ref,source,onSource}); + const title = section[0].toUpperCase()+section.slice(1); + const start = all.indexOf('### '+title+'\n\n') + ('### '+title+'\n\n').length; + const end = all.indexOf('\n\n### ',start); + return all.slice(start,end < 0 ? undefined : end); + } + if(id==='detail-shortcuts') { + const path='src/diffdevil/contracts/detail/v1/shortcuts.json', data=JSON.parse(read(path)); + return link('Canonical shortcut contract',path)+'\n\n'+table(['Alias','Canonical meaning'],Object.entries(data.aliases).map(([key,value])=>[code(key),code(value)]))+'\n\n'+table(['Projection field','Canonical field'],Object.entries(data.projectionFields).map(([key,value])=>[code(key),code(value)]))+'\n\n'+table(['Canonical measure ID'],data.canonicalMeasureIds.map(value=>[code(value)])); + } + if(id==='detail-environment') { + const path='src/diffdevil/contracts/detail/v1/environment.json', environment=JSON.parse(read(path)); + return link('Canonical environment contract',path)+'\n\n'+Object.entries(environment).map(([key,value])=>`### ${key}\n\n`+table(['Key / index','Value'],Object.entries(typeof value==='object'?value:{value}).map(([name,item])=>[code(name),code(typeof item==='object'?JSON.stringify(item):item)]))).join('\n\n'); + } + if(id==='detail-versions') { + const path='src/diffdevil/contracts/detail/v1/profile.json'; + return link('Canonical profile metadata',path)+'\n\n'+table(['Field','Value'],Object.entries(JSON.parse(read(path))).map(([key,value])=>[code(key),code(value)])); + } + if(id==='detail') { + const directory='src/diffdevil/contracts/detail/v1'; + const contract=name=>JSON.parse(read(directory+'/'+name+'.json')); + const {functions}=contract('functions'), {operators}=contract('operators'), {codes}=contract('diagnostics'), limits=contract('limits'); + if (![functions,operators,codes].every(items=>Array.isArray(items)&&items.length)) throw new Error('A selected detail catalogue is empty.'); + const inventory=table(['Canonical source'],files(root,directory).map(path=>[link(relative(directory,path).replaceAll('\\','/'),path)])); + return `### Operators\n\n${table(['Operator','Arity','Arguments','Result','Precedence','Associativity','Lazy'],operators.map(op=>[code(op.symbol),op.arity,op.arguments.map(code).join(', '),code(op.result),op.precedence,op.associativity,op.lazy]))}\n\n` + + `### Functions\n\n${table(['Function','Signatures','Evaluation'],functions.map(fn=>[code(fn.name),fn.signatures.map(code).join('; '),fn.evaluation]))}\n\n` + + `### Diagnostics\n\n${table(['Code','Phase','Meaning'],codes.map(row=>[code(row.code),row.defaultPhase,row.meaning]))}\n\n` + + `### Limits\n\n${link('Canonical limits and standing',directory+'/limits.json')}\n\n${table(['Limit','Value'],Object.entries(limits).map(([name,value])=>[code(name),code(typeof value === 'object' ? JSON.stringify(value) : String(value))]))}\n\n` + + `### Language contract sources\n\n${inventory}`; + } + if(['presenter-small','presenter-first-plan','presenter-report','presenter-plan'].includes(id)) { + const report='docs/examples/reports/exact.json', policy='docs/examples/policies/full.yml'; + const small=['presenter-small','presenter-first-plan'].includes(id); + const source=small?['--diff-file','docs/examples/diffs/review.diff','--no-config']:['--report',report,'--config',policy]; + const facts=JSON.parse(run(['analyze',...source,'--format','json'])); + const scalar=JSON.parse(run(['query',...source,'--expr','totals.lines.changed','--format','value'])); + assert.equal(scalar,small?10:178,'The small teaching input and large presenter input are different specimens.'); + if(small) assert.equal(JSON.parse(run(['query',...source,'--expr','totals.raw.churn','--format','value'])),16); + assert.equal(facts.kind,'diffdevil.report'); + const command=id==='presenter-first-plan'?['plan',...source,'--target-repo','example/repository','--target-pr','42']:id==='presenter-plan'?['plan',...source,'--target-repo','example/repository','--target-pr','42','--definitions','ensure']:['analyze',...source]; + return '```text\n'+run([...command,'--format','human','--color','never'])+'\n```'; + } + throw new Error(`Unknown generated island: ${id}`); +} diff --git a/apps/manual/manifest.mjs b/apps/manual/manifest.mjs new file mode 100644 index 00000000..27573262 --- /dev/null +++ b/apps/manual/manifest.mjs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Public routes are selected here, never inferred from maintained source paths. + * FAQ is a separately admitted website page. This manifest does not create its shell. + */ +import { origins } from '../website/public-origins.mjs'; +export { origins }; +export const packageSource = Object.freeze({ repository: 'Wolfsblvt/starlight-works', commit: '22d4567006ec7a33d890fab2f3d3515332498a90', name: '@wolfsblvt/starlight-works', version: '0.1.0', starlight: '0.42.2', astro: '7.3.3' }); +// key, title, source (relative to docs/manual), route, section, parent, wave, +// capability standing, old website routes. Array order is the selected reading order. +const rows = [ + ['what-is-diffdevil','What is diffdevil','README.md','/','Start',null,1,'current',['/docs/']], + ['see-changed-lines-on-github','See changed lines on GitHub','start/see-changed-lines-on-github.md','/start/see-changed-lines-on-github/','Start',null,1,'in-development',[]], + ['label-pull-requests','Label pull requests','start/label-pull-requests.md','/start/label-pull-requests/','Start',null,1,'current',['/docs/get-started/auto-label-pull-requests/','/docs/setup/actions/']], + ['analyze-local-changes','Analyze local changes','start/analyze-local-changes.md','/start/analyze-local-changes/','Start',null,1,'current',['/docs/get-started/local-automation/','/docs/setup/cli/']], + ['use-results-in-scripts','Use results in scripts','start/use-results-in-scripts.md','/start/use-results-in-scripts/','Start',null,1,'current',[]], + ['try-public-pull-request','Try a public pull request','start/try-a-public-pull-request.md','/start/try-a-public-pull-request/','Start',null,1,'current',[]], + ['surfaces','Surfaces','use/README.md','/use/','Use diffdevil',null,2,'current',['/docs/get-started/automation/','/docs/setup/everything/']], + ['cli','CLI','use/cli.md','/use/cli/','Use diffdevil','surfaces',2,'current',['/docs/cli/']], + ['github-actions','GitHub Actions','use/github-actions.md','/use/github-actions/','Use diffdevil','surfaces',2,'current',['/docs/actions/github-actions/']], + ['browser-extension','diffdevil for GitHub','use/browser-extension.md','/use/browser-extension/','Use diffdevil','surfaces',2,'in-development',['/docs/browser-extension/','/docs/browser-extension/integration/']], + ['managed-app','Managed App','use/managed-app/README.md','/use/managed-app/','Use diffdevil','surfaces',4,'in-development',['/docs/github-app/architecture/']], + ['managed-service','Use the managed service','use/managed-app/service.md','/use/managed-app/service/','Use diffdevil','managed-app',4,'in-development',['/docs/setup/app/']], + ['self-host-app','Self-host the App','use/managed-app/self-hosting.md','/use/managed-app/self-hosting/','Use diffdevil','managed-app',4,'in-development',['/docs/github-app/self-hosting/']], + ['typescript-library','TypeScript library','use/typescript-library.md','/use/typescript-library/','Use diffdevil','surfaces',2,'current',['/docs/library-api/']], + ['playground','Playground','use/playground.md','/use/playground/','Use diffdevil','surfaces',2,'current',['/docs/playground/']], + ['coding-agent','With a coding agent','use/coding-agent.md','/use/coding-agent/','Use diffdevil','surfaces',2,'current',['/docs/agents/agent-integration/','/docs/agents/skill/','/docs/agents/setup/','/docs/setup/skill/']], + ['shared-workflows','Shared workflows','use/shared-workflows/README.md','/use/shared-workflows/','Use diffdevil','surfaces',2,'current',[]], + ['reports-plans-and-apply','Reports, plans, and apply','use/shared-workflows/reports-plans-and-apply.md','/use/reports-plans-and-apply/','Use diffdevil','shared-workflows',2,'current',[]], + ['labels-comments-definitions','Labels, comments, and definitions','use/shared-workflows/labels-comments-and-definitions.md','/use/labels-comments-and-definitions/','Use diffdevil','shared-workflows',2,'current',[]], + ['start-with-a-preset','Start with a preset','policy/README.md','/policy/','Write policy',null,3,'current',['/docs/policies-and-detail/presets-and-shortcuts/']], + ['configure-policy','Configure policy','policy/configure.md','/policy/configure/','Write policy','start-with-a-preset',3,'current',[]], + ['paths-and-scopes','Paths and scopes','policy/paths-and-scopes.md','/policy/paths-and-scopes/','Write policy','start-with-a-preset',3,'current',[]], + ['measurements-to-rules','From measurements to rules','policy/from-measurements-to-rules/README.md','/policy/from-measurements-to-rules/','Write policy',null,3,'current',['/docs/policies-and-detail/policies-and-bands/']], + ['effects-and-templates','Effects and templates','policy/from-measurements-to-rules/effects-and-templates.md','/policy/effects-and-templates/','Write policy','measurements-to-rules',3,'current',['/docs/policies-and-detail/templates/']], + ['recipes','Recipes','policy/recipes.md','/policy/recipes/','Write policy',null,3,'current',['/docs/recipes/policy-recipes/']], + ['how-diffdevil-reasons','How diffdevil reasons','understand/README.md','/understand/','Understand',null,1,'current',[]], + ['changed-lines-and-raw-churn','Changed lines and raw churn','understand/changed-lines-and-churn.md','/understand/changed-lines-and-churn/','Understand','how-diffdevil-reasons',1,'current',[]], + ['evidence-and-uncertainty','Evidence and uncertainty','understand/evidence-and-uncertainty.md','/understand/evidence-and-uncertainty/','Understand','how-diffdevil-reasons',1,'current',[]], + ['from-facts-to-provider-state','From facts to provider state','understand/facts-to-provider-state.md','/understand/facts-to-provider-state/','Understand','how-diffdevil-reasons',1,'current',[]], + ['source-identity-trust-and-mutation','Source identity, trust, and mutation','understand/trust-and-mutation.md','/understand/trust-and-mutation/','Understand','how-diffdevil-reasons',1,'current',['/docs/actions/github-api/']], + ['interfaces','Interfaces','reference/README.md','/reference/','Reference',null,3,'current',[]], + ['cli-reference','CLI commands and output formats','reference/cli.md','/reference/cli/','Reference','interfaces',3,'current',['/docs/cli/presentation/']], + ['github-actions-reference','GitHub Actions reference','reference/github-actions.md','/reference/github-actions/','Reference','interfaces',3,'current',[]], + ['typescript-api-reference','TypeScript API reference','reference/typescript-api.md','/reference/typescript-api/','Reference','interfaces',3,'current',[]], + ['language-and-contracts','Language and contracts','reference/language-and-contracts/README.md','/reference/language-and-contracts/','Reference',null,3,'current',['/docs/policies-and-detail/language/']], + ['schemas-and-compatibility','Schemas and compatibility','reference/language-and-contracts/schemas-and-compatibility.md','/reference/schemas-and-compatibility/','Reference','language-and-contracts',3,'current',['/docs/policies-and-detail/versioning-and-interchange/']], + ['detail-language','detail language','reference/language-and-contracts/detail-language.md','/reference/detail-language/','Reference','language-and-contracts',3,'current',['/docs/policies-and-detail/syntax/','/docs/policies-and-detail/types-and-measurements/','/docs/policies-and-detail/collections-and-scopes/','/docs/policies-and-detail/standard-library/','/docs/policies-and-detail/diagnostics-and-limits/']], + ['faq','FAQ','faq.md','/faq/','Help',null,0,'current',[]], + ['troubleshooting','Troubleshooting','help/troubleshooting.md','/help/troubleshooting/','Help',null,4,'current',[]], + ['security-and-data','Security and data','help/security-and-data.md','/help/security-and-data/','Help',null,4,'current',['/docs/github-app/privacy-and-data/','/docs/security/']], + ['technical-project-docs','Technical and project documentation','help/technical-and-project-documentation.md','/help/technical-and-project-documentation/','Help',null,4,'current',[]], + ['releases','Releases','help/releases.md','/help/releases/','Help',null,4,'current',['/docs/releases/']], +]; +const closed = new Set(['managed-app','interfaces','language-and-contracts']); +export const sections = Object.freeze(['Start','Use diffdevil','Write policy','Understand','Reference','Help']); +export const pages = Object.freeze(rows.map(([key,title,file,route,section,parent,wave,availability,legacyRoutes], order) => Object.freeze({ + key, title, sidebarLabel: key === 'browser-extension' ? 'Browser extension' : title, source: `docs/manual/${file}`, sourceId: `docs/manual/${file}`, route, host: key === 'faq' ? 'site' : 'docs', kind: key === 'faq' ? 'FAQ' : 'DOCS', section, parent, order, wave, availability, + aliases: key === 'what-is-diffdevil' ? ['/start/what-is-diffdevil/'] : [], legacyRoutes, defaultOpen: !closed.has(key), +}))); +export const manualPages = Object.freeze(pages.filter(page => page.host === 'docs')); +export const byKey = new Map(pages.map(page => [page.key,page])); +export function pageUrl(key) { const page = byKey.get(key); if (!page) throw new Error(`Unknown manual page: ${key}`); return origins[page.host] + page.route; } +/** Native leaves and linked groups; source names never enter public URLs. */ +export function sidebar({ includeFAQ = false } = {}) { + function item(page) { + const children = pages.filter(child => child.parent === page.key); + const destination = page.host === 'docs' ? { slug: page.route === '/' ? 'index' : page.route.slice(1,-1) } : { link: pageUrl(page.key) }; + return children.length ? { label: page.sidebarLabel, ...destination, defaultOpen: page.defaultOpen, items: children.map(item) } : { label: page.sidebarLabel, ...destination }; + } + return sections.map(section => ({ label: section.toUpperCase(), defaultOpen: section !== 'Reference', items: pages.filter(page => page.section === section && page.parent === null && (includeFAQ || page.key !== 'faq')).map(item) })); +} +export function validateManifest(selected = pages) { + const ids = new Set(), sources = new Set(), routes = new Set(); + for (const page of selected) { + if (!/^[a-z][a-z0-9-]*$/u.test(page.key) || ids.has(page.key)) throw new Error(`Duplicate or invalid page key: ${page.key}`); + if (!/^docs\/manual\/(?:[a-z0-9-]+\/)*(?:[a-z0-9-]+|README)\.md$/u.test(page.source) || sources.has(page.source) || page.sourceId !== page.source) throw new Error(`Duplicate or invalid source id: ${page.source}`); + if (!['site','docs'].includes(page.host) || !/^\/(?:[a-z0-9-]+\/)*$/u.test(page.route)) throw new Error(`Invalid public route: ${page.route}`); + if (!['current','in-development'].includes(page.availability)) throw new Error(`Invalid capability standing: ${page.key}`); + for (const route of [page.route,...page.aliases]) { const coordinate = `${page.host}:${route}`; if (routes.has(coordinate)) throw new Error(`Duplicate route: ${coordinate}`); routes.add(coordinate); } + ids.add(page.key); sources.add(page.source); + } + for (const page of selected) { + const seen = new Set([page.key]); let parent = page.parent; + while (parent) { if (seen.has(parent)) throw new Error(`Circular sidebar: ${page.key}`); seen.add(parent); const target = selected.find(item => item.key === parent); if (!target || target.section !== page.section) throw new Error(`Invalid sidebar parent: ${page.key}`); parent = target.parent; } + } + return true; +} +validateManifest(); diff --git a/apps/manual/migration.mjs b/apps/manual/migration.mjs new file mode 100644 index 00000000..6fe4e623 --- /dev/null +++ b/apps/manual/migration.mjs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { byKey, pages, pageUrl } from './manifest.mjs'; + +// Exact source-family dispositions. Retained families are not resolver path globs. +// A transfer never changes state merely because its destination file exists. +const rows = [ + ['docs/automation.md','split-retire',['what-is-diffdevil','surfaces','shared-workflows','start-with-a-preset']], + ['docs/guides/auto-label-pull-requests.md','split-retire',['label-pull-requests','github-actions']], + ['docs/guides/local-automation.md','split-retire',['analyze-local-changes','use-results-in-scripts','cli']], + ['docs/guides/policy-recipes.md','move-split-retire',['recipes','start-with-a-preset','configure-policy','paths-and-scopes','measurements-to-rules','effects-and-templates']], + ['docs/integration/cli.md','split-retire',['cli','cli-reference']], + ['docs/integration/github-actions.md','split-retire',['github-actions','github-actions-reference']], + ['docs/integration/typescript-api.md','split-retire',['typescript-library','typescript-api-reference']], + ['docs/integration/playground.md','split-retire-or-operator-residue',['try-public-pull-request','playground']], + ['docs/integration/presets-and-shortcuts.md','split-retire',['start-with-a-preset','configure-policy','language-and-contracts']], + ['docs/integration/templates.md','split-retire',['effects-and-templates','labels-comments-definitions']], + ['docs/language.md','split-retire',['measurements-to-rules','language-and-contracts']], + ['docs/language/policies-and-bands.md','split-retire',['measurements-to-rules','language-and-contracts']], + ['docs/language/types-and-measurements.md','split-retire',['evidence-and-uncertainty','detail-language']], + ['docs/language/collections-and-scopes.md','split-retire',['paths-and-scopes','detail-language']], + ['docs/language/syntax.md','merge-retire',['detail-language']], + ['docs/language/standard-library.md','merge-retire',['detail-language']], + ['docs/language/diagnostics-and-limits.md','split-retire',['detail-language','troubleshooting']], + ['docs/language/versioning-and-interchange.md','move-split-retire',['schemas-and-compatibility','language-and-contracts']], + ['docs/releases/README.md','move-split-or-maintainer-map',['releases']], +]; +export const migrations = Object.freeze(rows.map(([source,disposition,targets]) => Object.freeze({ source, disposition, targets, primary: targets[0] }))); +export const retainedFamilies = Object.freeze([ + { sources: ['skills/diffdevil/**'], disposition: 'retain-canonical-install-artifact', targets: ['coding-agent'] }, + { sources: ['docs/setup/skill.md','docs/setup/cli.md','docs/setup/actions.md','docs/setup/app.md','docs/setup/everything.md'], disposition: 'retain-rendered-raw-payloads', targets: ['coding-agent','label-pull-requests','analyze-local-changes','managed-service','surfaces'] }, + { sources: ['docs/integration/example-catalogue.md','docs/examples/catalogue/**'], disposition: 'retain-shared-catalogue', targets: ['try-public-pull-request','playground'], externalRoute: '/examples/' }, + { sources: ['README.md'], disposition: 'retain-crafted-quick-manual', targets: ['what-is-diffdevil','label-pull-requests','analyze-local-changes'] }, + { sources: ['docs/README.md'], disposition: 'retain-technical-map-update-links', targets: ['technical-project-docs'] }, + { sources: ['docs/VISION.md','docs/DIRECTION.md','docs/DECISIONS.md','docs/ARCHITECTURE.md','docs/DEVELOPMENT.md','docs/PROJECT-MAP.md','docs/qualification.md','docs/publication-boundary.md'], disposition: 'retain-repository-owned', targets: ['technical-project-docs'] }, + { sources: ['docs/reference/**'], disposition: 'retain-dated-evidence', targets: ['technical-project-docs'] }, + { sources: ['apps/*/README.md','provider/runtime internals'], disposition: 'retain-component-operator-homes', targets: ['technical-project-docs','self-host-app'] }, + { sources: ['SECURITY.md','LICENSES/README.md','docs/PRIVACY-AND-DATA.md'], disposition: 'retain-authoritative-specialist-sources', targets: ['security-and-data','technical-project-docs'] }, +]); +export function transferFor(source, state) { return state.transfers?.[source]; } +export function isRetired(source, state) { return ['retired','operator-residue','maintainer-map'].includes(transferFor(source,state)?.phase); } +export function pageIsCurrent(key, state) { + if (state.pages?.[key]?.status !== 'authored') return false; + return migrations.filter(row => row.targets.includes(key)).every(row => isRetired(row.source,state)); +} +/** Refuse a partial cut. Observations are from actual files, links and rendered anchors. */ +export function validateTransfer(row, transfer, state, observed) { + if (!transfer || transfer.phase === 'current') return; + if (!['retired','operator-residue','maintainer-map'].includes(transfer.phase)) throw new Error(`Invalid transfer phase: ${row.source}`); + for (const key of row.targets) if (state.pages?.[key]?.status !== 'authored') throw new Error(`${row.source}: successor not authored: ${key}`); + if (!row.targets.includes(transfer.primary)) throw new Error(`${row.source}: choose an explicit primary successor`); + if (transfer.phase === 'retired' && observed.sourceExists) throw new Error(`${row.source}: remove the old source, not a forwarding Markdown stub`); + if (transfer.phase === 'operator-residue' && (!row.disposition.includes('operator-residue') || !observed.sourceExists || !transfer.residueJob)) throw new Error(`${row.source}: operator residue needs its separate retained job`); + if (transfer.phase === 'maintainer-map' && (!row.disposition.includes('maintainer-map') || !observed.sourceExists || !transfer.residueJob)) throw new Error(`${row.source}: maintainer map needs its separate retained job`); + if (!Array.isArray(transfer.oldAnchors) || !transfer.fromSourceSha256 || !/^[a-f0-9]{64}$/u.test(transfer.fromSourceSha256)) throw new Error(`${row.source}: capture the exact pre-cutover source digest and anchor inventory`); + if (observed.inboundLinks.length) throw new Error(`${row.source}: inbound links remain: ${observed.inboundLinks.join(', ')}`); + for (const old of transfer.oldAnchors) { + const target = transfer.anchors?.[old]; + if (!target || !row.targets.includes(target.page) || !observed.targetAnchors[target.page]?.includes(target.anchor)) throw new Error(`${row.source}#${old}: missing or invalid successor anchor`); + } + for (const [old,target] of Object.entries(transfer.anchors ?? {})) if (!transfer.oldAnchors.includes(old) || !row.targets.includes(target.page) || !observed.targetAnchors[target.page]?.includes(target.anchor)) throw new Error(`${row.source}: unqualified fragment mapping: ${old}`); + if (transfer.redirects !== true || transfer.sourceIds !== true || transfer.search !== true) throw new Error(`${row.source}: route, source-id and search transfer must be selected together`); +} +/** The plan is build evidence, not an activation command or a second work queue. */ +export function cutoverPlan(state) { + return migrations.map(row => ({ ...row, phase: transferFor(row.source,state)?.phase ?? 'current', waitingFor: row.targets.filter(key => state.pages?.[key]?.status !== 'authored'), targetUrls: row.targets.map(pageUrl) })); +} +/** Legacy page aliases are emitted only after their full source family transfers. + * Routes with retained specialist owners receive explicit per-route admission at the final join. + */ +export function legacyRedirects(state) { + const rules = []; + const known = new Set(pages.flatMap(page => page.legacyRoutes)); + for (const route of Object.keys(state.routes ?? {})) if (!known.has(route)) throw new Error(`Unknown legacy route: ${route}`); + for (const page of pages) for (const from of page.legacyRoutes) { + const selection = state.routes?.[from]; + if (!selection) continue; + if (selection.target !== page.key || !pageIsCurrent(page.key,state)) throw new Error(`Premature or mismatched legacy route transfer: ${from}`); + rules.push({ from, to: pageUrl(page.key), status: 308 }); + } + return rules; +} +export function migratedSource(source,state) { + const transfer = transferFor(source,state); + return isRetired(source,state) ? byKey.get(transfer.primary)?.source : undefined; +} + +/** Every retired emitted page needs its explicit route transfer in the same cut. */ +export function validateRetiredRoutes(state, entries, redirects) { + const emitted = new Set(redirects.map(rule=>rule.from)); + for (const entry of entries) { + if (!isRetired(entry.source,state)) continue; + const route = entry.route ?? (entry.slug ? `/docs/${entry.slug}/` : '/docs/'); + if (!emitted.has(route)) throw new Error(`${entry.source}: missing legacy route transfer: ${route}`); + } +} + +/** Compatibility anchors supplement, never duplicate, the current article headings. */ +export function fragmentAliases(state,anchorInventory,entries = []) { + const fragments = {}; + const append = (key, anchors) => { + const map = fragments[key] ??= {}; + for (const [old,destination] of Object.entries(anchors ?? {})) { + const url = pageUrl(destination.page)+'#'+encodeURIComponent(destination.anchor); + const unchanged = pageUrl(key)+'#'+encodeURIComponent(old); + if ((map[old] && map[old] !== url) || (anchorInventory[key]?.includes(old) && url !== unchanged)) throw new Error(`Ambiguous old fragment: ${old}`); + if (url !== unchanged) map[old] = url; + } + }; + for (const [source,transfer] of Object.entries(state.transfers ?? {})) { + if (transfer.phase === 'current') continue; + const landings = new Set([transfer.primary]); + for (const entry of entries.filter(entry=>entry.source===source)) { + const route = entry.route ?? (entry.slug ? `/docs/${entry.slug}/` : '/docs/'); + if (state.routes?.[route]) landings.add(state.routes[route].target); + } + for (const key of landings) append(key, transfer.anchors); + } + for (const selection of Object.values(state.routes ?? {})) { + if (selection.projection) append(selection.target, selection.projection.anchors); + } + return fragments; +} + +/** A retained specialist/raw source keeps its GitHub identity. Only its old public + * projection moves. Captured source bytes and every old section have one destination. + */ +export function validateProjection(route, selection, observed) { + const projection = selection.projection; + if (!projection) return; + if (observed.source !== projection.source || observed.digest !== projection.fromSourceSha256) throw new Error(`${route}: retained source capture does not match Git`); + const same = (a,b) => JSON.stringify([...new Set(a)].sort()) === JSON.stringify([...new Set(b)].sort()); + if (!same(observed.anchors,projection.oldAnchors)) throw new Error(`${route}: retained projection fragment inventory does not match its source`); + if (!same(Object.keys(projection.anchors),projection.oldAnchors)) throw new Error(`${route}: incomplete retained projection mapping`); + for (const [old,target] of Object.entries(projection.anchors)) { + if (!observed.targetAnchors[target.page]?.includes(target.anchor)) throw new Error(`${route}#${old}: invalid successor fragment`); + } +} + +/** Complete apex cutover: former reader pages move to the manual and retained + * technical projections move to their exact allow-listed repository bytes. */ +export function siteRedirects(state, entries, targets) { + const rules = [...legacyRedirects(state)]; + for (const entry of entries.filter(entry => entry.repositoryOnly)) { + const target = targets[entry.source]; + if (!target) throw new Error(`Unqualified repository-only destination: ${entry.source}`); + rules.push({from: entry.route ?? `/docs/${entry.slug}/`, to: target.url, status: 308}); + } + const selected = new Set(); + for (const rule of rules) { + if (selected.has(rule.from)) throw new Error(`Duplicate legacy route: ${rule.from}`); + selected.add(rule.from); + } + for (const entry of entries) { + const route = entry.route ?? (entry.slug ? `/docs/${entry.slug}/` : '/docs/'); + if (!selected.has(route)) throw new Error(`Legacy projection has no disposition: ${route}`); + } + validateRetiredRoutes(state, entries, rules); + return rules; +} diff --git a/apps/manual/package.json b/apps/manual/package.json new file mode 100644 index 00000000..0d46f495 --- /dev/null +++ b/apps/manual/package.json @@ -0,0 +1,34 @@ +{ + "name": "diffdevil-public-manual", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "toolchain": "node ../../tools/manual-package.mjs", + "build": "node ../../tools/public-build.mjs", + "check": "npm --prefix ../.. run build && node ../../tools/manual-command.mjs check", + "dev": "npm --prefix ../.. run build && node ../../tools/manual-command.mjs dev", + "test": "node --test *.test.mjs", + "test:projection": "node --test render.spec.mjs", + "qa": "node ../../tools/manual-qa.mjs", + "verify:offline": "node ../../tools/verify-offline.mjs", + "generate": "npm --prefix ../.. run build && node generated-content.mjs --write", + "check:generated": "node generated-content.mjs --check" + }, + "dependencies": { + "@astrojs/check": "0.9.10", + "@astrojs/markdown-remark": "7.3.1", + "@astrojs/starlight": "0.42.2", + "@wolfsblvt/starlight-works": "file:../../artifacts/manual-toolchain/package", + "astro": "7.3.3", + "remark-gfm": "4.0.1", + "remark-parse": "11.0.0", + "remark-stringify": "11.0.0", + "typescript": "6.0.3", + "unified": "11.0.5", + "github-slugger": "2.0.0" + } +} diff --git a/apps/manual/qa/reading.md b/apps/manual/qa/reading.md new file mode 100644 index 00000000..4f643122 --- /dev/null +++ b/apps/manual/qa/reading.md @@ -0,0 +1,55 @@ +# Reading qualification + +This isolated page exercises the actual manual renderer. It is only included by the +qualification build and is never indexed or part of normal navigation. + +## Five meanings + +> [!NOTE] +> Additional context. + +> [!TIP] +> A useful alternative. + +> [!IMPORTANT] +> A prerequisite that must not be missed. + +> [!WARNING] +> A consequence to consider before continuing. + +> [!CAUTION] +> An action with an irreversible consequence. + +## A complete open example + +```sh +# An intentionally long, inert line exercises bounded inner scrolling; it is not a product command. +printf '%s\n' 'abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz' +``` + +The example above stays open. Only the supplementary variant is collapsed. + +
+Supplementary variant + +A secondary variation remains available without hiding prerequisites or consequences. + +
+ +## Reference links and a selected asset + +[CLI source][cli] and [FAQ](../../../docs/manual/faq.md#changed-vs-churn) are ordinary source links. + +[cli]: ../../../docs/manual/use/cli.md + +![The accepted monochrome diffdevil interface mark](../../browser-extension/assets/diffdevil-brand.svg) + +## Bounded data + +| Field | Value | Why this is here | +| --- | --- | --- | +| Source | `an-intentionally-long-unbroken-value-that-must-not-force-the-page-itself-to-scroll-horizontally-at-any-viewport-width` | The table may use a bounded inner scrolling region. | + +## Focus return + +[Return to the manual](../../../docs/manual/README.md). diff --git a/apps/manual/qa/reconciliation.mjs b/apps/manual/qa/reconciliation.mjs new file mode 100644 index 00000000..375b9a19 --- /dev/null +++ b/apps/manual/qa/reconciliation.mjs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Final cross-surface qualification uses the actual emitted files and handlers. + * These checks are author/integration evidence, not the independent final review. + */ +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { expect } from '@playwright/test'; +import { pageUrl } from '../manifest.mjs'; +import { entries } from '../../website/docs-manifest.mjs'; +import { publishedRelease, downloadKinds } from '../../website/download-source.mjs'; + +export async function qualifyReconciliation({ page, origins, check, screenshot, handlers, asset, manifest, records, ref }) { + const state = JSON.parse(readFileSync('apps/manual/authoring-state.json', 'utf8')); + await page.emulateMedia({ forcedColors: 'none', reducedMotion: 'reduce', colorScheme: 'dark' }); + await page.setViewportSize({ width: 1280, height: 900 }); + await check('Every legacy route has one emitted 308, including retained technical and historical sources', async () => { + const redirects = new Map(manifest.redirects.map(rule => [rule.from, rule])); + for (const entry of entries) { + const path = entry.route ?? (entry.slug ? '/docs/' + entry.slug + '/' : '/docs/'); + const rule = redirects.get(path); assert.ok(rule, path); + const request = new Request(origins.site + path + '?view=agent&example=bounded-decisions&variant=bounded'); + const response = await handlers.site.fetch(request, { ASSETS: { fetch: req => asset(req, 'website') } }); + assert.equal(response.status, 308, path); + const destination = new URL(response.headers.get('location')); + assert.equal(destination.search, new URL(request.url).search, path); + assert.equal(destination.origin + destination.pathname, rule.to, path); + if (entry.repositoryOnly) assert.ok(destination.pathname.startsWith('/Wolfsblvt/diffdevil/blob/' + ref + '/'), path); + const fallback = readFileSync('artifacts/website/dist' + path + 'index.html', 'utf8'); + assert.match(fallback, /noindex/u); assert.doesNotMatch(fallback, /data-pagefind-body/u); + } + }); + await check('Retained-source projection fragments reach current sections without dropping queries', async () => { + let exercised = 0; + for (const selection of Object.values(state.routes)) { + if (!selection.projection) continue; + const old = selection.projection.oldAnchors.find(anchor => { + const target = selection.projection.anchors[anchor]; + return target && (target.page !== selection.target || target.anchor !== anchor); + }); + if (!old) continue; + exercised++; + const destination = selection.projection.anchors[old]; + // The emitted 308 and its query handling are checked above. Playwright's + // route interception cannot serve the second hop of that redirect, so + // start this browser-owned fragment check at the selected landing page. + await page.goto(pageUrl(selection.target) + '?view=agent#' + encodeURIComponent(old)); + await expect(page).toHaveURL(pageUrl(destination.page) + '?view=agent#' + encodeURIComponent(destination.anchor)); + await expect(page.locator('[id="' + destination.anchor + '"]').first()).toBeVisible(); + } + assert.ok(exercised > 0, 'Expected a retained source with a moved fragment'); + }); + await check('Product pages link directly to canonical chapters; raw setup resolves or explicitly refuses', async () => { + for (const path of ['/', '/app/', '/extension/', '/examples/', '/faq/']) { + await page.goto(origins.site + path); + const old = await page.locator('a[href]').evaluateAll(nodes => nodes.map(node => node.href).filter(href => { + const url = new URL(href); return url.hostname === 'diffdevil.dev' && url.pathname.startsWith('/docs/'); + })); + assert.deepEqual(old, [], path); + } + for (const intent of ['skill', 'cli', 'actions', 'app', 'everything']) { + const text = readFileSync('artifacts/website/dist/setup/' + intent + '.md', 'utf8'); + assert.doesNotMatch(text, /\{\{[A-Z_]+\}\}/u, intent); + assert.ok(text.startsWith('#'), intent); + if (intent === 'app' && !(process.env.PUBLIC_APP_INSTALL_URL && process.env.PUBLIC_DASHBOARD_URL)) { + assert.match(text, /not available/u); + assert.doesNotMatch(text, /github\.com\/apps\//u); + } + } + assert.equal(readFileSync('artifacts/website/dist/skill/SKILL.md', 'utf8'), readFileSync('skills/diffdevil/SKILL.md', 'utf8')); + await page.goto(origins.site + '/#agents-skill'); + const instruction = await page.locator('#agents-skill [data-copy]').getAttribute('data-copy'); + assert.ok(instruction.includes('/setup/skill.md')); + assert.equal(instruction.includes('from ' + origins.site + '/skill/SKILL.md'), false); + }); + await check('Download routes use exact manifest assets or a visible unavailable state', async () => { + const release = publishedRelease(); + for (const kind of downloadKinds) { + const response = await page.goto(origins.site + '/downloads/' + kind + '/'); + assert.equal(response.status(), 200); + if (release) { + await expect(page.locator('a[href="' + release.assets[kind].url + '"]')).toBeVisible(); + await expect(page.locator('pre')).toContainText(release.assets[kind].sha256); + } else { + await expect(page.locator('main')).toContainText('Archive not available'); + assert.equal(await page.locator('main a[href*="/releases/download/"]').count(), 0); + } + for (const width of [1280, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1), true, kind + '/' + width); + } + } + await screenshot('download-unavailable-or-published-320.png'); + }); + await check('Final search has one current home per result and no former apex manual or downloads leakage', async () => { + const site = records.records.filter(record => record.kind === 'SITE').map(record => new URL(record.url).pathname).sort(); + assert.deepEqual(site, ['/', '/app/', '/examples/', '/extension/', '/playground/']); + assert.equal(records.records.filter(record => record.kind === 'DOCS').length, manifest.records.filter(record => record.current).length); + assert.equal(records.records.filter(record => record.kind === 'FAQ').length, 20); + assert.equal(records.records.length, new Set(records.records.map(record => record.url)).size); + assert.equal(existsSync('apps/website/src/content/docs/index.mdx'), false); + }); + await check('A public-host theme handoff creates no shared account or authentication state', async () => { + await page.goto(origins.site + '/?dd-theme=light.dark'); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.locator('.primary-nav a').filter({ hasText: /^Docs$/u }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme-pref', 'light'); + assert.equal(new URL(page.url()).searchParams.has('dd-theme'), false); + assert.deepEqual(await page.context().cookies(), []); + await expect(page.locator('h1').first()).toBeVisible(); + }); +} diff --git a/apps/manual/qa/redirects.mjs b/apps/manual/qa/redirects.mjs new file mode 100644 index 00000000..1c8306be --- /dev/null +++ b/apps/manual/qa/redirects.mjs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import {readFileSync} from 'node:fs'; +import {legacyRedirects} from '../migration.mjs'; + +/** Exercise real HTTP redirects through the emitted handlers. Playwright's route + * interception does not cover a redirect's second hop, so this journey uses three + * loopback origins. Only the network origin in Location is adapted; production + * status/path/query are observed before adaptation and fragments stay browser-owned. + */ +export async function qualifyRedirects(browser, handlers, asset, origins) { + const servers = [], local = new Map(), observed = [], failures = []; + let context; + try { + for (const canonical of [origins.site,origins.docs,origins.compatibility]) { + const host = canonical === origins.docs ? 'docs' : 'site'; + const server = createServer(async (incoming,outgoing) => { + try { + const url = new URL(incoming.url,canonical); + const request = new Request(url,{method:incoming.method}); + const response = await handlers[host].fetch(request,{ + ASSETS:{fetch:request=>asset(request,host === 'docs' ? 'manual' : 'website')}, + }); + const headers = Object.fromEntries(response.headers); + if (headers.location) { + observed.push({from:url.href,status:response.status,to:headers.location}); + const target = new URL(headers.location); + const origin = local.get(target.origin); + if (!origin) throw new Error(`Redirect left selected hosts: ${target.href}`); + headers.location = origin + target.pathname + target.search + target.hash; + } + outgoing.writeHead(response.status,headers); + outgoing.end(Buffer.from(await response.arrayBuffer())); + } catch (error) { + failures.push(String(error)); + outgoing.writeHead(500,{'Content-Type':'text/plain'}); + outgoing.end('Redirect qualification failed'); + } + }); + servers.push(server); + await new Promise((resolve,reject) => { + server.once('error',reject); + server.listen(0,'127.0.0.1',resolve); + }); + local.set(canonical,`http://127.0.0.1:${server.address().port}`); + } + context = await browser.newContext({javaScriptEnabled:false}); + const page = await context.newPage(); + const responses = []; + page.on('response',response=>{if(response.status() === 308) responses.push(response.url());}); + let response = await page.goto(local.get(origins.docs)+'/start/what-is-diffdevil/?keep=1#related-questions'); + assert.equal(response.status(),200); + assert.equal(page.url(),local.get(origins.docs)+'/?keep=1#related-questions'); + assert.equal(await page.locator('h1').first().textContent(),'What is diffdevil'); + response = await page.goto(local.get(origins.compatibility)+'/faq/?keep=2#changed-vs-churn'); + assert.equal(response.status(),200); + assert.equal(page.url(),local.get(origins.site)+'/faq/?keep=2#changed-vs-churn'); + assert.equal(await page.locator('[data-faq-page]').count(),1); + const expected = [ + {from:origins.docs+'/start/what-is-diffdevil/?keep=1',status:308,to:origins.docs+'/?keep=1'}, + {from:origins.compatibility+'/faq/?keep=2',status:308,to:origins.site+'/faq/?keep=2'}, + ]; + const state=JSON.parse(readFileSync('apps/manual/authoring-state.json','utf8')); + for(const redirect of legacyRedirects(state)){ + const from=new URL(redirect.from,origins.site),to=new URL(redirect.to); + const response=await page.goto(local.get(from.origin)+from.pathname+'?migration=1#kept-fragment'); + assert.equal(response.status(),200,redirect.from); + assert.equal(page.url(),local.get(to.origin)+to.pathname+'?migration=1#kept-fragment'); + expected.push({from:from.href+'?migration=1',status:308,to:redirect.to+'?migration=1'}); + } + assert.equal(responses.length,expected.length); + assert.deepEqual(observed,expected); + assert.deepEqual(failures,[]); + return {transport:'loopback HTTP through emitted handlers',observed,browserFragmentInheritance:true}; + } finally { + await context?.close(); + await Promise.all(servers.map(server=>new Promise(resolve=>{ + server.closeAllConnections(); + server.close(resolve); + }))); + } +} diff --git a/apps/manual/qa/run.mjs b/apps/manual/qa/run.mjs new file mode 100644 index 00000000..6ffef979 --- /dev/null +++ b/apps/manual/qa/run.mjs @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Exercise actual static files under intercepted canonical HTTPS origins. */ +import { pathToFileURL } from 'node:url'; +import { chromium, expect } from '@playwright/test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs'; +import { join, extname, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { manualPages, origins, pageUrl } from '../manifest.mjs'; +import { editRef } from '../source-resolver.mjs'; +import { faqRecords } from '../../website/faq-content.mjs'; +import { qualifyRedirects } from './redirects.mjs'; +import { qualifyFirstSuccess } from './wave-1.mjs'; +import { qualifySurfaces } from './wave-2.mjs'; +import { qualifyPolicyReference } from './wave-3.mjs'; +import { qualifyManagedHelp } from './wave-4.mjs'; +import { qualifyReconciliation } from './reconciliation.mjs'; +const root = resolve('.'), out = join(root,'artifacts/manual/qa'); +mkdirSync(out,{recursive:true}); +const ref = execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); +const editBranch = editRef(root); +const packageVersion = JSON.parse(readFileSync('package.json','utf8')).version; +const manifest = JSON.parse(readFileSync('artifacts/manual/manifest.json','utf8')); +assert.equal(manifest.ref,ref); assert.equal(manifest.qa,true); +const records = JSON.parse(readFileSync('artifacts/public-search/records.json','utf8')); +const faq = faqRecords(readFileSync('artifacts/website/dist/faq/index.html','utf8')); +const result = {ref,checks:[],failures:[],unexpectedRequests:[],pageErrors:[],screenshots:[],limitations:['Intercepted canonical origins and loopback HTTP, not live deployment.','DOM/keyboard evidence, not an actual screen-reader user journey.']}; +const mime = {'.md':'text/markdown; charset=utf-8','.html':'text/html','.css':'text/css','.js':'text/javascript','.mjs':'text/javascript','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.ico':'image/x-icon','.woff2':'font/woff2','.woff':'font/woff','.webp':'image/webp','.wasm':'application/wasm','.pf_fragment':'application/octet-stream','.pf_index':'application/octet-stream','.pf_meta':'application/octet-stream'}; +const browser = await chromium.launch({executablePath:process.env.PLAYWRIGHT_EXECUTABLE_PATH}); +const context = await browser.newContext({viewport:{width:1280,height:900},colorScheme:'dark',reducedMotion:'reduce'}); +const handlers = { + site: (await import(pathToFileURL(join(root,'artifacts/website/dist/_worker.js')).href)).default, + docs: (await import(pathToFileURL(join(root,'artifacts/manual/dist/_worker.js')).href)).default, +}; +async function asset(request, host) { + const url = new URL(request.url); + let path; + try { path = decodeURIComponent(url.pathname); } catch { return new Response('Bad path',{status:400}); } + if (path.split('/').includes('..') || path.includes('\\')) return new Response('Bad path',{status:400}); + let file = join(root,`artifacts/${host}/dist`,path); + if (path.endsWith('/')) file=join(file,'index.html'); + if (!existsSync(file) || !statSync(file).isFile()) return new Response('Not found',{status:404}); + return new Response(readFileSync(file),{headers:{'Content-Type':mime[extname(file)]??'application/octet-stream'}}); +} +const serve = async route=>{ + const url = new URL(route.request().url()); + if (![origins.site,origins.docs,origins.compatibility].includes(url.origin)) { + result.unexpectedRequests.push(url.href); await route.abort(); return; + } + const host = url.origin===origins.docs?'docs':'site'; + const request = new Request(url.href,{method:route.request().method()}); + // Execute the emitted production handler. Only the static-assets service binding + // is local; redirect behavior is not reimplemented by this browser fixture. + const response = await handlers[host].fetch(request,{ASSETS:{fetch:request=>asset(request,host==='docs'?'manual':'website')}}); + await route.fulfill({status:response.status,headers:Object.fromEntries(response.headers),body:Buffer.from(await response.arrayBuffer())}); +}; +await context.route('**/*',serve); +const page = await context.newPage(); +page.on('pageerror',error=>result.pageErrors.push(error.message)); +async function check(name,fn) { + try { await fn(); result.checks.push(name); console.log('PASS '+name); } + catch(error) { result.failures.push({name,error:error.stack??String(error)}); console.error('FAIL '+name+': '+error.message); await page.screenshot({path:join(out,`failure-${result.failures.length}.png`),fullPage:true}).catch(()=>{}); } +} +async function screenshot(name) { await page.screenshot({path:join(out,name),fullPage:true}); result.screenshots.push(name); } +try { + await check('Every selected manual route has its actual H1 and exact source/edit provenance',async()=>{ + for (const selected of manualPages) { + const response = await page.goto(pageUrl(selected.key)); assert.equal(response.status(),200,selected.key); + await expect(page.locator('h1').first()).toHaveText(selected.title); + await expect(page.locator('.docs-provenance a').filter({hasText:'View source'})).toHaveAttribute('href',`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${selected.source}`); + await expect(page.locator('.docs-provenance a').filter({hasText:'Source snapshot'})).toHaveAttribute('href',`https://github.com/Wolfsblvt/diffdevil/tree/${ref}`); + await expect(page.locator('.docs-provenance a').filter({hasText:'Source snapshot'})).toHaveText(`Source snapshot ${ref.slice(0,7)}`); + await expect(page.locator('.docs-provenance')).toContainText(`Root package metadata · v${packageVersion}`); + await expect(page.locator('.docs-provenance')).not.toContainText(/Applies to v\d/u); + await expect(page.locator('.docs-provenance a').filter({hasText:'Edit on GitHub'})).toHaveAttribute('href',`https://github.com/Wolfsblvt/diffdevil/edit/${editBranch}/${selected.source}`); + assert.equal(await page.locator('link[rel=canonical]').getAttribute('href'),pageUrl(selected.key)); + const current = manifest.records.find(row=>row.key===selected.key).current; + if (!current) assert.match(await page.locator('meta[name=robots]').getAttribute('content'),/noindex/u); + } + assert.equal(existsSync('artifacts/manual/dist/faq/index.html'),false); + }); + await check('Root alias and www compatibility are matching 308 rules preserving query and fragment',async()=>{ + result.redirects = await qualifyRedirects(browser,handlers,asset,origins); + }); + await check('Resolver succeeds only for allow-listed IDs and refuses arbitrary files, URLs and duplicate parameters',async()=>{ + await page.goto(origins.site+'/source/?f=docs%2Fmanual%2Fuse%2Fcli.md#related-questions'); + await expect(page.locator('[data-source-result]')).toHaveAttribute('href',`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/docs/manual/use/cli.md#related-questions`); + await expect(page.locator('[data-source-result]')).toBeFocused(); + for (const query of ['f=../secret','f=https://example.org/','f=__proto__','f=docs/manual/missing.md','f=README.md&f=SECURITY.md']) { + await page.goto(origins.site+'/source/?'+query); + await expect(page.locator('[data-source-result]')).toBeHidden(); + await expect(page.locator('[data-source-status]')).toHaveText('No recognized source was selected.'); + } + }); + await check('Linked categories and disclosure controls remain independent and keyboard operable',async()=>{ + await page.goto(origins.docs+'/'); + const managed=page.locator('[data-slw-group="Managed App"]'); + const details=managed.locator(':scope > details'); + await expect(details).not.toHaveAttribute('open',''); + const control=details.locator(':scope > summary'); await control.focus(); await page.keyboard.press('Space'); + await expect(details).toHaveAttribute('open',''); + assert.equal(await managed.locator(':scope > .slw-group-heading a').count(),1); + const target=await control.getAttribute('aria-controls'); assert.equal(await page.locator('[id="'+target+'"]').count(),1); + await managed.locator(':scope > .slw-group-heading a').click(); await expect(page.locator('h1').first()).toHaveText('Managed App'); + await expect(page.locator('[data-slw-group="Managed App"] > details')).toHaveAttribute('open',''); + await page.goto(pageUrl('cli-reference')); + await expect(page.locator('[data-slw-group="REFERENCE"] > details')).toHaveAttribute('open',''); + await expect(page.locator('[data-slw-group="Interfaces"] > details')).toHaveAttribute('open',''); + await page.goto(origins.docs+'/'); + await expect(page.locator('[data-slw-group="REFERENCE"] > details')).not.toHaveAttribute('open',''); + }); + await check('Five shared-package alerts and an open complete example render in the real article',async()=>{ + await page.goto(origins.docs+'/__qualification/reading/'); + const kinds=await page.locator('[data-slw-alert]').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('data-slw-alert').toUpperCase()).sort()); + assert.deepEqual(kinds,['CAUTION','IMPORTANT','NOTE','TIP','WARNING']); + await expect(page.locator('.sl-markdown-content pre').first()).toBeVisible(); + const extra=page.locator('.sl-markdown-content details'); await expect(extra).not.toHaveAttribute('open',''); + await extra.locator('summary').focus(); await page.keyboard.press('Enter'); await expect(extra).toHaveAttribute('open',''); + assert.equal(await page.locator('.sl-markdown-content img').evaluate(image=>image.complete && image.naturalWidth>0),true); + await screenshot('reading-dark-desktop.png'); + }); + await check('Theme continuity uses the shared control, owned handoff and before-paint state',async()=>{ + await page.goto(origins.site+'/'); + const control=page.locator('[data-theme-control]').first(); + await expect(page.locator('html')).toHaveAttribute('data-theme-pref','dark'); + await control.click(); await expect(page.locator('html')).toHaveAttribute('data-theme-pref','auto'); + await control.click(); await expect(page.locator('html')).toHaveAttribute('data-theme-pref','light'); + await page.locator('.primary-nav a').filter({hasText:/^Docs$/u}).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme-pref','light'); assert.equal(new URL(page.url()).origin,origins.docs); assert.equal(new URL(page.url()).searchParams.has('dd-theme'),false); + await page.locator('.primary-nav a').filter({hasText:/^FAQ$/u}).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme-pref','light'); assert.equal(new URL(page.url()).pathname,'/faq/'); + await page.goto(origins.docs+'/__qualification/reading/'); await screenshot('reading-light-desktop.png'); + }); + await check('Related FAQ anchors open their existing disclosure without a second shell',async()=>{ + await page.goto(pageUrl('changed-lines-and-raw-churn')); + await page.locator('.sl-markdown-content a[href*="/faq/#changed-vs-churn"]').click(); + assert.equal(new URL(page.url()).pathname,'/faq/'); assert.equal(new URL(page.url()).hash,'#changed-vs-churn'); + await expect(page.locator('#changed-vs-churn')).toBeVisible(); + const article=page.locator('[data-faq-id="changed-vs-churn"]'); await expect(article.locator('details')).toHaveAttribute('open',''); + assert.equal(await page.locator('.slw-sidebar').count(),0); + }); + await check('Joined search carries SITE, DOCS and question-level FAQ records without leakage',async()=>{ + assert.equal(records.ref,ref); + assert.deepEqual([...new Set(records.records.map(record=>record.kind))].sort(),['DOCS','FAQ','SITE']); + assert.equal(records.records.filter(record=>record.kind==='FAQ').length,faq.length); + const indexedManual = records.records.filter(record=>new URL(record.url).hostname==='docs.diffdevil.dev').map(record=>new URL(record.url).pathname).sort(); + assert.deepEqual(indexedManual,manifest.records.filter(record=>record.current).map(record=>record.route).sort()); + assert.equal(records.records.some(record=>['/source/','/privacy/','/impressum/'].includes(new URL(record.url).pathname) || new URL(record.url).pathname.startsWith('/__qualification/')),false); + const hash=file=>createHash('sha256').update(readFileSync(file)).digest('hex'); + assert.equal(hash('artifacts/website/dist/pagefind/pagefind.js'),hash('artifacts/manual/dist/pagefind/pagefind.js')); + await page.goto(origins.docs+'/'); await page.keyboard.press('Control+k'); + const dialog=page.locator('[data-search-dialog]'); await expect(dialog).toBeVisible(); + const input=page.locator('[data-search-input]'); await expect(input).toBeFocused(); + await input.fill('GitHub already shows additions'); + await expect(page.locator('[data-search-results] a[data-kind="faq"]').first()).toBeVisible({timeout:15000}); + await page.keyboard.press('ArrowDown'); assert.equal(await page.locator('[data-search-results] a').first().evaluate(node=>node===document.activeElement),true); + await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); + }); + await check('Narrow/mobile and forced-colors layouts keep page width bounded with named controls',async()=>{ + for (const width of [1280,1024,768,640,390,320]) { + await page.setViewportSize({width,height:900}); await page.goto(origins.docs+'/__qualification/reading/'); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth+1),true,`Horizontal page overflow at ${width}px`); + for (const button of await page.locator('button:visible').all()) assert.ok((await button.getAttribute('aria-label')) || (await button.getAttribute('aria-labelledby')) || (await button.getAttribute('title')) || (await button.innerText()).trim(),`Unnamed button at ${width}px`); + if(width===390 || width===320) await screenshot(`reading-${width}.png`); + } + const menu=page.locator('.sl-menu-button'); await expect(menu).toBeVisible(); await menu.focus(); await page.keyboard.press('Enter'); + await expect(page.locator('#starlight__sidebar')).toHaveJSProperty('popover','auto'); + assert.equal(await page.locator('#starlight__sidebar').evaluate(node=>node.matches(':popover-open')),true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#starlight__sidebar').evaluate(node=>node.matches(':popover-open')),false); + await page.emulateMedia({forcedColors:'active',reducedMotion:'reduce'}); await screenshot('reading-forced-colors.png'); + await page.locator('.sl-markdown-content details > summary').focus(); + assert.notEqual(await page.locator('.sl-markdown-content details > summary').evaluate(node=>getComputedStyle(node).outlineStyle),'none'); + }); + await check('Native linked groups and article disclosures work without JavaScript',async()=>{ + const plain=await browser.newContext({javaScriptEnabled:false,viewport:{width:1280,height:900}}); + await plain.route('**/*',serve); + try { + const reader=await plain.newPage(); + await reader.goto(origins.docs+'/__qualification/reading/'); + const group=reader.locator('[data-slw-group="Managed App"] > details'); + await expect(group).not.toHaveAttribute('open',''); + await group.locator(':scope > summary').focus(); await reader.keyboard.press('Enter'); + await expect(group).toHaveAttribute('open',''); + await reader.locator('.sl-markdown-content details > summary').click(); + await expect(reader.locator('.sl-markdown-content details')).toHaveAttribute('open',''); + await reader.locator('[data-slw-group="Managed App"] > .slw-group-heading a').click(); + await expect(reader.locator('h1').first()).toHaveText('Managed App'); + await expect(reader.locator('[data-slw-group="Managed App"] > details')).toHaveAttribute('open',''); + } finally { await plain.close(); } + }); + await qualifyFirstSuccess({ page, origins, check, screenshot }); + await qualifySurfaces({ page, origins, check, screenshot }); + await qualifyPolicyReference({ page, origins, check, screenshot }); + await qualifyManagedHelp({ page, origins, check, screenshot }); + await qualifyReconciliation({ page, origins, check, screenshot, handlers, asset, manifest, records, ref }); + await check('Shared shell has no page-script errors or unexpected external requests',async()=>{ + assert.deepEqual(result.pageErrors,[]); assert.deepEqual(result.unexpectedRequests,[]); + }); +} finally { + result.ok=result.failures.length===0; + writeFileSync(join(out,'result.json'),JSON.stringify(result,null,2)+'\n'); + await context.close(); await browser.close(); +} +if (!result.ok) process.exitCode=1; diff --git a/apps/manual/qa/wave-1.mjs b/apps/manual/qa/wave-1.mjs new file mode 100644 index 00000000..cb45f393 --- /dev/null +++ b/apps/manual/qa/wave-1.mjs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** The authored first-success route uses the actual built Playground and exports. */ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; +import { parse } from 'yaml'; +import { readFileSync } from 'node:fs'; +export async function qualifyFirstSuccess({ page, origins, check, screenshot }) { + await check('Authored root and complete first-success examples remain readable at desktop and 320px', async () => { + await page.emulateMedia({ forcedColors: 'none' }); + for (const width of [1280, 320]) { + await page.setViewportSize({ width, height: 900 }); + for (const route of ['/', '/start/analyze-local-changes/', '/start/label-pull-requests/', '/start/use-results-in-scripts/', '/understand/evidence-and-uncertainty/']) { + await page.goto(origins.docs + route); + const article = page.locator('.sl-markdown-content'); + assert.ok((await article.locator('p').count()) > 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1), true, route + ' at ' + width); + if (route === '/start/use-results-in-scripts/') { + // Both complete consumers are open, not hidden in a disclosure. + assert.equal(await article.locator('pre').filter({ hasText: '#!/usr/bin/env bash' }).count(), 1); + assert.equal(await article.locator('pre').filter({ hasText: 'Set-StrictMode' }).count(), 1); + assert.equal(await article.locator('details pre').count(), 0); + } + if (route === '/') await screenshot(`first-success-root-${width}.png`); + } + } + }); + await check('The manual opens the exact frozen lesson, changes an exclusion and exports separate replayable artifacts', async () => { + const snapshot = JSON.parse(readFileSync(new URL('../../../docs/examples/catalogue/snapshots/vite-18968.json', import.meta.url), 'utf8')); + const headUrl = 'http://127.0.0.1:4173/api/head?url=' + encodeURIComponent(snapshot.url); + let probes = 0; + // This single read-only probe is fixture HTTP, not a live freshness assertion. + // Every other external request still reaches the suite's refusing guard. + const headFixture = async route => { + if (route.request().method() !== 'GET') return route.fallback(); + probes++; + await route.fulfill({ status: 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify({ + kind: 'diffdevil.playground-response', schemaVersion: '1.0', ok: true, status: 200, canonicalUrl: snapshot.url, + head: { head: snapshot.provenance.head, base: snapshot.provenance.baseTip, state: 'open', changedFiles: snapshot.report.files.length }, + }) }); + }; + await page.route(headUrl, headFixture); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(origins.docs + '/start/try-a-public-pull-request/'); + await page.locator('.sl-markdown-content a[href*="example=lockfile-scope&variant=all"]').first().click(); + await expect(page.locator('.pg-primary')).toContainText('22', { timeout: 15000 }); + await expect(page.locator('.pg-strip')).toContainText('teaching snapshot'); + await page.getByRole('textbox', { name: 'Add an exclusion pattern, then press Enter' }).fill('pnpm-lock.yaml'); + await page.getByRole('textbox', { name: 'Add an exclusion pattern, then press Enter' }).press('Enter'); + await expect(page.locator('.pg-files .is-excluded')).toHaveCount(1); + await page.locator('.pg-export').click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('tab', { name: 'report.json / plan.json', exact: true }).click(); + const code = dialog.locator('pre code'); + const report = JSON.parse(await code.innerText()); + assert.equal(report.kind, 'diffdevil.report'); + assert.equal(report.source.repository, 'vitejs/vite'); + assert.equal(report.source.head, '48db5c9555f2d43cb88367506e79854fce8ed47d'); + assert.equal(report.totals.lines.changed.value, 1); + assert.equal(report.totals.raw.churn.value, 2); + await dialog.getByRole('combobox', { name: 'Artifact' }).selectOption('plan'); + const plan = JSON.parse(await code.innerText()); + assert.equal(plan.kind, 'diffdevil.plan'); assert.equal(plan.stage, 'desired'); + assert.ok(plan.operations.some(operation => operation.selected === 'size/XS')); + await dialog.getByRole('tab', { name: '.diffdevil.yml', exact: true }).click(); + assert.ok(parse(await code.innerText()).defaults.paths.exclude.includes('pnpm-lock.yaml')); + await dialog.getByRole('tab', { name: 'Complete workflow', exact: true }).click(); + const workflow = parse(await code.innerText()); + assert.equal(workflow.permissions['contents'], 'read'); + assert.equal(workflow.permissions['pull-requests'], 'write'); + await dialog.getByRole('tab', { name: 'CLI', exact: true }).click(); + assert.match(await code.innerText(), /analyze --report report\.json/u); + assert.match(await code.innerText(), /plan --report report\.json --config \.diffdevil\.yml/u); + assert.doesNotMatch(await code.innerText(), /--base|\bapply\s+--/u); + await screenshot('first-success-snapshot-export.png'); + await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); + // A malformed public URL must not become a new zero-valued result. + await page.getByRole('tab', { name: 'Public pull request', exact: true }).click(); + const input = page.getByPlaceholder('https://github.com/owner/repo/pull/123'); + await input.fill('https://example.org/not-a-github-pr'); + await page.getByRole('button', { name: 'Analyze public PR', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('Not a public github.com pull-request URL'); + await expect.poll(() => probes).toBeGreaterThan(0); + } finally { + await page.unroute(headUrl, headFixture); + } + }); +} diff --git a/apps/manual/qa/wave-2.mjs b/apps/manual/qa/wave-2.mjs new file mode 100644 index 00000000..43eb7e88 --- /dev/null +++ b/apps/manual/qa/wave-2.mjs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import assert from 'node:assert/strict'; +import {expect} from '@playwright/test'; +import {readFileSync} from 'node:fs'; +import {manualPages} from '../manifest.mjs'; +export async function qualifySurfaces({page,origins,check,screenshot}) { + await check('Every open-surface article reads at desktop and 320px with complete examples kept open',async()=>{ + await page.emulateMedia({forcedColors:'none'}); + for(const width of [1280,320]){ + await page.setViewportSize({width,height:900}); + for(const selected of manualPages.filter(page=>page.wave===2)){ + await page.goto(origins.docs+selected.route); + await expect(page.locator('h1').first()).toHaveText(selected.title); + const article=page.locator('.sl-markdown-content'); + assert.ok(await article.locator('p').count(),selected.key); + assert.equal(await article.locator('details pre').count(),0,selected.key+' must not hide full working examples'); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth+1),true,selected.route+' at '+width); + if(selected.key==='browser-extension'){ + const image=article.locator('img[src="/assets/manual/settings-policy-bands-light.png"]'); + await expect(image).toHaveCount(1); + await expect.poll(()=>image.evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true); + if(width===1280)await screenshot('wave-2-extension-settings.png'); + } + if(selected.key==='typescript-library')await expect(article.locator('pre').filter({hasText:'function requireResult'})).toHaveCount(1); + } + } + }); + await check('Browser extension keeps its product title and sidebar label; shared workflows join real articles',async()=>{ + await page.setViewportSize({width:1280,height:900}); + await page.goto(origins.docs+'/use/browser-extension/'); + await expect(page.locator('.sidebar a').filter({hasText:/^Browser extension$/})).toHaveCount(1); + await page.goto(origins.docs+'/use/shared-workflows/'); + await page.locator('.sl-markdown-content a[href="'+origins.docs+'/use/reports-plans-and-apply/"]').first().click(); + await expect(page.locator('h1').first()).toHaveText('Reports, plans, and apply'); + await expect(page.locator('.sl-markdown-content pre').filter({hasText:'--output desired.json'})).toBeVisible(); + await screenshot('wave-2-report-lifecycle.png'); + }); + await check('Raw Skill setup is the exact canonical source served by the built product',async()=>{ + await page.goto(origins.site+'/'); + const response=await page.evaluate(async()=>{ + const response=await fetch('/setup/skill.md'); + return {status:response.status,text:await response.text()}; + }); + assert.equal(response.status,200); + assert.equal(response.text,readFileSync('docs/setup/skill.md','utf8')); + }); + await check('Retired source fragments select the right successor and shared-workflow pages enter joined search',async()=>{ + const state=JSON.parse(readFileSync('apps/manual/authoring-state.json','utf8')); + const ref=JSON.parse(readFileSync('artifacts/manual/manifest.json','utf8')).ref; + for(const [source,transfer] of Object.entries(state.transfers)){ + for(const [old,target] of Object.entries(transfer.anchors)){ + const selected=manualPages.find(page=>page.key===target.page); + await page.goto(origins.site+'/source/?f='+encodeURIComponent(source)+'#'+old); + await expect(page.locator('[data-source-result]')).toHaveAttribute('href',`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${selected.source}#${target.anchor}`); + } + } + // Same-document Back/Forward must reselect the destination, not retain a stale link. + const [source,transfer]=Object.entries(state.transfers)[0]; + const [[first,firstTarget],[second,secondTarget]]=Object.entries(transfer.anchors); + const expected=target=>`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${manualPages.find(page=>page.key===target.page).source}#${target.anchor}`; + await page.goto(origins.site+'/source/?f='+encodeURIComponent(source)+'#'+first); + await page.goto(origins.site+'/source/?f='+encodeURIComponent(source)+'#'+second); + await page.goBack(); + await expect(page.locator('[data-source-result]')).toHaveAttribute('href',expected(firstTarget)); + await page.goForward(); + await expect(page.locator('[data-source-result]')).toHaveAttribute('href',expected(secondTarget)); + await page.evaluate(()=>{location.hash='%E0%A4%A';}); + await expect(page.locator('[data-source-result]')).toBeHidden(); + await expect(page.locator('[data-source-result]')).not.toHaveAttribute('href'); + const {records}=JSON.parse(readFileSync('artifacts/public-search/records.json','utf8')); + for(const route of ['/use/playground/','/use/coding-agent/','/use/reports-plans-and-apply/']) + assert.ok(records.some(record=>record.url===origins.docs+route||record.canonical===origins.docs+route),route); + for(const route of Object.keys(state.routes))assert.equal(records.some(record=>record.url===origins.site+route||record.canonical===origins.site+route),false,route); + }); +} diff --git a/apps/manual/qa/wave-3.mjs b/apps/manual/qa/wave-3.mjs new file mode 100644 index 00000000..279a14fd --- /dev/null +++ b/apps/manual/qa/wave-3.mjs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Exercise the authored policy/reference family through the built reading surface. */ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { manualPages } from '../manifest.mjs'; + +export async function qualifyPolicyReference({page, origins, check, screenshot}) { + await check('Policy and reference articles retain open complete examples at two widths in both themes', async()=>{ + await page.emulateMedia({forcedColors:'none'}); + for(const theme of ['light','dark']) for(const width of [1280,320]) { + await page.setViewportSize({width,height:900}); + for(const selected of manualPages.filter(page=>page.wave===3)) { + await page.goto(origins.docs+selected.route); + await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;document.documentElement.dataset.themePref=theme;},theme); + await expect(page.locator('h1').first()).toHaveText(selected.title); + const article=page.locator('.sl-markdown-content'); + assert.ok(await article.locator('p').count(),selected.key); + assert.equal(await article.locator('details pre').count(),0,selected.key); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth+1),true,selected.key+' '+theme+' '+width); + if(width===320&&['start-with-a-preset','typescript-api-reference','schemas-and-compatibility','detail-language'].includes(selected.key)) await screenshot(`wave-3-${selected.key}-${theme}-${width}.png`); + } + } + }); + await check('Reference deep links preserve exact declarations and repository-only language architecture',async()=>{ + await page.setViewportSize({width:1280,height:900}); + await page.goto(origins.docs+'/reference/typescript-api/'); + await page.locator('.sl-markdown-content a[href="#api-policy-evaluate-evaluatepolicyquery"]').first().click(); + assert.equal(new URL(page.url()).hash,'#api-policy-evaluate-evaluatepolicyquery'); + await expect(page.locator('[id="api-policy-evaluate-evaluatepolicyquery"]')).toHaveCount(1); + await page.goto(origins.docs+'/reference/detail-language/#syntax-and-source-locations'); + await expect(page.locator('#syntax-and-source-locations')).toBeVisible(); + await expect(page.locator('.sl-markdown-content a[href*="f=docs%2Flanguage%2Fparser-architecture.md"]')).toHaveCount(1); + const {records}=JSON.parse(readFileSync('artifacts/public-search/records.json','utf8')); + const manifest=JSON.parse(readFileSync('artifacts/manual/manifest.json','utf8')); + for(const selected of manifest.records.filter(page=>page.wave===3&&page.current)) assert.ok(records.some(record=>record.url===origins.docs+selected.route),selected.key); + assert.equal(records.some(record=>record.url===origins.site+'/docs/policies-and-detail/parser-architecture/'),false); + }); +} diff --git a/apps/manual/qa/wave-4.mjs b/apps/manual/qa/wave-4.mjs new file mode 100644 index 00000000..a34f2748 --- /dev/null +++ b/apps/manual/qa/wave-4.mjs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Real reading routes only; no invented dashboard or provider-facing interaction. */ +import assert from 'node:assert/strict'; +import {expect} from '@playwright/test'; +import {readFileSync} from 'node:fs'; +import {manualPages,pageUrl} from '../manifest.mjs'; +import {entries as legacyEntries} from '../../website/docs-manifest.mjs'; +export async function qualifyManagedHelp({page,origins,check,screenshot}) { + await check('Seven Managed App and Help articles reflow in both themes without hiding operating prerequisites',async()=>{ + await page.emulateMedia({forcedColors:'none'}); + for(const theme of ['light','dark'])for(const width of [1280,320]){ + await page.setViewportSize({width,height:900}); + for(const selected of manualPages.filter(p=>p.wave===4)){ + await page.goto(pageUrl(selected.key)); + await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;document.documentElement.dataset.themePref=theme;},theme); + await expect(page.locator('h1').first()).toHaveText(selected.title); + const article=page.locator('.sl-markdown-content'); + assert.ok(await article.locator('p').count()); + assert.equal(await article.locator('details pre').count(),0,selected.key); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=document.documentElement.clientWidth+1),true,selected.key+' '+width+' '+theme); + assert.equal(await page.locator('meta[name="diffdevil-availability"]').getAttribute('content'),selected.availability); + if(width===320&&['self-host-app','troubleshooting'].includes(selected.key))await screenshot(`wave-4-${selected.key}-${theme}-${width}.png`); + } + } + }); + await check('Managed adoption, self-hosting, repair and exact operator source form one reader journey',async()=>{ + await page.setViewportSize({width:1280,height:900}); + await page.goto(pageUrl('managed-app')); + await page.locator(`.sl-markdown-content a[href="${pageUrl('managed-service')}"]`).first().click(); + await expect(page.locator('h1').first()).toHaveText('Use the managed service'); + await page.goto(pageUrl('self-host-app')); + await expect(page.locator('.sl-markdown-content pre').filter({hasText:'d1 migrations apply APP_DB --local'})).toHaveCount(1); + await page.locator('.sl-markdown-content a[href*="f=apps%2Fgithub-app%2FREADME.md"]').first().click(); + const ref=JSON.parse(readFileSync('artifacts/manual/manifest.json','utf8')).ref; + await expect(page.locator('[data-source-result]')).toHaveAttribute('href',`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/apps/github-app/README.md`); + await page.goto(pageUrl('troubleshooting')+'#an-app-operation-needs-repair'); + await expect(page.locator('#an-app-operation-needs-repair')).toBeVisible(); + await page.locator(`.sl-markdown-content a[href="${pageUrl('self-host-app')}#observe-and-repair-by-stage"]`).click(); + await expect(page.locator('#observe-and-repair-by-stage')).toBeVisible(); + await screenshot('wave-4-recovery-desktop.png'); + }); + await check('Five retired language fragment mappings reach rendered semantic destinations including the Troubleshooting split',async()=>{ + const state=JSON.parse(readFileSync('apps/manual/authoring-state.json','utf8')); + // The emitted route-level 308s are exercised separately by qualifyRedirects. + // URL fragments are browser-owned and never reach the server, so a split source + // can map one fragment to another semantic owner only through the maintained + // fragment ledger/source resolver rather than by pretending the 308 can inspect it. + for(const [old,fragment,key,anchor] of [ + ['syntax','source-unit-and-character-model','detail-language','source-unit-and-character-model'], + ['types-and-measurements','numeric-evidence-states','detail-language','numeric-evidence-states'], + ['collections-and-scopes','strict-output-boundary','detail-language','strict-output-boundary'], + ['standard-library','errors-and-discovery','detail-language','errors-and-discovery'], + ['diagnostics-and-limits','coordinates','troubleshooting','a-diagnostic-points-into-yaml-an-expression-or-a-unicode-line'], + ]){ + assert.deepEqual(state.transfers['docs/language/'+old+'.md'].anchors[fragment],{page:key,anchor}); + await page.goto(pageUrl(key)+'#'+anchor); + await expect(page.locator('[id="'+anchor+'"]').first()).toBeVisible(); + } + }); + await check('Joined current search contains all seven chapters and no historical or retired duplicate',async()=>{ + const {records}=JSON.parse(readFileSync('artifacts/public-search/records.json','utf8')); + for(const selected of manualPages.filter(p=>p.wave===4||p.key==='detail-language'))assert.ok(records.some(r=>r.url===pageUrl(selected.key)),selected.key); + for(const entry of legacyEntries.filter(e=>e.historical)){ + const route=entry.route??`/docs/${entry.slug}/`; + assert.equal(records.some(r=>r.url===origins.site+route),false,entry.source); + } + assert.equal(records.some(r=>r.url?.includes('/docs/policies-and-detail/')),false); + await page.goto(pageUrl('technical-project-docs')); + await page.locator('.sl-markdown-content a[href*="f=docs%2Freleases%2Fv1.0.0.md"]').click(); + await expect(page.locator('[data-source-result]')).toHaveAttribute('href',/\/docs\/releases\/v1\.0\.0\.md$/u); + }); +} diff --git a/apps/manual/reconciliation.test.mjs b/apps/manual/reconciliation.test.mjs new file mode 100644 index 00000000..1db06edd --- /dev/null +++ b/apps/manual/reconciliation.test.mjs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, readFileSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import {join} from 'node:path'; +import {manualPages, pages, pageUrl, origins} from './manifest.mjs'; +import {migrations, siteRedirects, validateProjection, pageIsCurrent} from './migration.mjs'; +import {sourceTargets, resolveSource} from './source-resolver.mjs'; +import {fragmentDestination} from './fragment-destination.mjs'; +import {createStaticHandler} from './static-handler.mjs'; +import {indexable, qualifyFaqRecords} from './search-index.mjs'; +import {entries} from '../website/docs-manifest.mjs'; +import {faqRecords} from '../website/faq-content.mjs'; + +const root=fileURLToPath(new URL('../../',import.meta.url)); +const read=path=>readFileSync(join(root,path),'utf8'); +const state=JSON.parse(read('apps/manual/authoring-state.json')); +const ref=execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(); +const targets=sourceTargets({ref,state,exists:path=>existsSync(join(root,path)),legacySources:entries.map(entry=>entry.source)}); + +test('The complete manual and separate FAQ have no competing current apex article',()=>{ + assert.equal(manualPages.length,41); + assert.equal(pages.filter(page=>page.kind==='FAQ').length,1); + for(const page of manualPages)assert.equal(pageIsCurrent(page.key,state),true,page.key); + const routes=siteRedirects(state,entries,targets); + assert.equal(new Set(routes.map(rule=>rule.from)).size,routes.length); + for(const entry of entries){ + const route=entry.route??(entry.slug?`/docs/${entry.slug}/`:'/docs/'); + const destination=routes.find(rule=>rule.from===route); + assert.ok(destination,route); + if(entry.repositoryOnly)assert.equal(destination.to,targets[entry.source].url); + else assert.equal(destination.to,pageUrl(state.routes[route].target)); + } + assert.throws(()=>siteRedirects(state,[...entries,{source:'unknown.md',slug:'unknown'}],targets),/no disposition/u); + assert.throws(()=>siteRedirects(state,entries,{}),/Unqualified repository/u); +}); + +test('All emitted route destinations preserve query values without reaching an external provider',async()=>{ + const rules=siteRedirects(state,entries,targets); + const handler=createStaticHandler({...origins,host:'site',redirects:rules}); + const assets={ASSETS:{fetch:()=>{throw new Error('A selected redirect reached static assets');}}}; + for(const rule of rules){ + const response=await handler.fetch(new Request(origins.site+rule.from+'?keep=a&keep=b%20c&f=ignored'),assets); + assert.equal(response.status,308,rule.from); + const expected=new URL(rule.to);expected.search='?keep=a&keep=b%20c&f=ignored'; + assert.equal(response.headers.get('location'),expected.href,rule.from); + } + for(const path of ['/faq/','/docs/cli/','/playground/']){ + const response=await handler.fetch(new Request(origins.compatibility+path+'?q=kept'),assets); + assert.equal(response.status,308);assert.equal(response.headers.get('location'),origins.site+path+'?q=kept'); + } +}); + +test('Old fragment handoff preserves repeated query values and refuses malformed or unmapped input',()=>{ + const target=pageUrl('troubleshooting')+'#an-app-operation-needs-repair'; + const aliases={old:target}; + assert.equal(fragmentDestination(pageUrl('detail-language')+'?q=one&q=two%20words#old',aliases),target.replace('#','?q=one&q=two%20words#')); + assert.equal(fragmentDestination(pageUrl('detail-language')+'#%6Fld',aliases),target); + assert.equal(fragmentDestination(pageUrl('detail-language')+'#%ZZ',aliases),null); + assert.equal(fragmentDestination(pageUrl('detail-language')+'#other',aliases),null); + assert.equal(fragmentDestination('not a URL',aliases),null); + assert.equal(fragmentDestination(pageUrl('cli')+'?q=keep#same',{same:pageUrl('cli')+'#same'}),null); +}); + +test('Retained raw and technical sources keep exact GitHub identities while their old projections move',()=>{ + for(const [route,selection] of Object.entries(state.routes)){ + const capture=selection.projection;if(!capture)continue; + const bytes=execFileSync('git',['show',`${capture.fromRef}:${capture.source}`],{cwd:root}); + assert.equal(createHash('sha256').update(bytes).digest('hex'),capture.fromSourceSha256,route); + assert.equal(resolveSource('/source/?f='+encodeURIComponent(capture.source),targets),`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${capture.source}`); + assert.deepEqual(Object.keys(capture.anchors).sort(),[...new Set(capture.oldAnchors)].sort()); + } + const observed={source:'retained.md',digest:'a'.repeat(64),anchors:['_top','old'],targetAnchors:{cli:['_top','current']}}; + const selection={projection:{source:'retained.md',fromSourceSha256:observed.digest,oldAnchors:observed.anchors,anchors:{_top:{page:'cli',anchor:'_top'},old:{page:'cli',anchor:'current'}}}}; + assert.doesNotThrow(()=>validateProjection('/old/',selection,observed)); + assert.throws(()=>validateProjection('/old/',selection,{...observed,digest:'b'.repeat(64)}),/capture/u); + assert.throws(()=>validateProjection('/old/',selection,{...observed,anchors:['_top']}),/inventory/u); + assert.throws(()=>validateProjection('/old/',selection,{...observed,targetAnchors:{cli:['_top']}}),/successor/u); +}); + +test('Retired families have successors, no forwarding files and finite fragment-safe source IDs',()=>{ + for(const family of migrations){ + const transfer=state.transfers[family.source];assert.ok(transfer,family.source); + if(transfer.phase==='retired')assert.equal(existsSync(join(root,family.source)),false,family.source); + else assert.ok(transfer.residueJob&&existsSync(join(root,family.source)),family.source); + for(const [old,destination] of Object.entries(transfer.anchors)){ + const selected=pages.find(page=>page.key===destination.page);assert.ok(selected); + assert.equal(resolveSource('/source/?f='+encodeURIComponent(family.source)+'#'+encodeURIComponent(old),targets),`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${selected.source}#${encodeURIComponent(destination.anchor)}`); + } + } + for(const query of ['f=../secret','f=https://example.org','f=constructor','f=__proto__','f=README.md&f=README.md'])assert.equal(resolveSource('/source/?'+query,targets),null); +}); + +test('Current search accepts reader pages and rejects every legacy, technical, historical and whole-FAQ route',()=>{ + const body='
Reader content
'; + for(const page of manualPages)assert.equal(indexable(page.route,body),true,page.key); + for(const entry of entries)assert.equal(indexable(entry.route??`/docs/${entry.slug}/`,body),false,entry.source); + for(const path of ['/faq/','/source/','/privacy/','/impressum/','/404/','/__qualification/reading/'])assert.equal(indexable(path,body),false,path); + for(const path of ['/','/playground/','/examples/','/extension/','/app/'])assert.equal(indexable(path,body),true,path); + const faq=qualifyFaqRecords(faqRecords(read('docs/manual/faq.md'))); + assert.equal(faq.length,20);assert.equal(new Set(faq.map(item=>item.canonical)).size,faq.length); + assert.throws(()=>qualifyFaqRecords([...faq,faq[0]]),/unique/u); +}); diff --git a/apps/manual/related-questions.json b/apps/manual/related-questions.json new file mode 100644 index 00000000..195f92b4 --- /dev/null +++ b/apps/manual/related-questions.json @@ -0,0 +1,22 @@ +{ + "what-is-diffdevil": ["beyond-size-labels", "why-managed"], + "see-changed-lines-on-github": ["playground-or-extension", "extension-without-repo-setup"], + "label-pull-requests": ["beyond-size-labels", "automation-overlap", "trusted-policy"], + "analyze-local-changes": ["non-javascript-projects", "no-language-required"], + "use-results-in-scripts": ["no-language-required"], + "cli": ["different-results", "no-language-required"], + "github-actions": ["automation-overlap", "trusted-policy"], + "browser-extension": ["playground-or-extension", "extension-without-repo-setup", "different-results"], + "managed-app": ["why-managed", "leaving-managed-service"], + "managed-service": ["why-managed", "app-history"], + "self-host-app": ["leaving-managed-service"], + "playground": ["playground-or-extension", "code-data"], + "coding-agent": ["coding-agents", "non-javascript-projects"], + "start-with-a-preset": ["no-language-required", "semantic-versioning"], + "paths-and-scopes": ["files-and-exclusions"], + "effects-and-templates": ["automation-overlap"], + "changed-lines-and-raw-churn": ["changed-vs-churn", "changed-makes-sense"], + "evidence-and-uncertainty": ["measurement-evidence"], + "source-identity-trust-and-mutation": ["trusted-policy", "code-data"], + "security-and-data": ["code-data", "app-history"] +} diff --git a/apps/manual/render.mjs b/apps/manual/render.mjs new file mode 100644 index 00000000..ab0da43b --- /dev/null +++ b/apps/manual/render.mjs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, cpSync, statSync } from 'node:fs'; +import { dirname, join, posix, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkGfm from 'remark-gfm'; +import remarkStringify from 'remark-stringify'; +import GithubSlugger from 'github-slugger'; +import { manualPages, pages, pageUrl, validateManifest } from './manifest.mjs'; +import { migrations, validateTransfer, cutoverPlan, siteRedirects, pageIsCurrent, fragmentAliases, validateProjection } from './migration.mjs'; +import { sourceTargets, sourceResolverUrl, sourceUrl, sourceRef, editRef } from './source-resolver.mjs'; +import { projectIslands } from './generated-content.mjs'; +import { entries as legacyEntries } from '../website/docs-manifest.mjs'; +import { faqRecords, FAQ_SOURCE, FAQ_CANONICAL } from '../website/faq-content.mjs'; +import relatedQuestions from './related-questions.json' with { type: 'json' }; +import publicAssets from './assets.json' with { type: 'json' }; + +export { sourceRef } from './source-resolver.mjs'; +export const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); +const parser = unified().use(remarkParse).use(remarkGfm); +const printer = unified().use(remarkStringify, { bullet: '-', fences: true }).use(remarkGfm); +function walk(node, visit) { visit(node); for (const child of node.children ?? []) walk(child, visit); } +function text(node) { return node.value ?? (node.children ?? []).map(text).join(''); } +export function anchorsOf(markdown) { + const slugger = new GithubSlugger(), result = []; + walk(parser.parse(markdown), node => { + if (node.type === 'heading') result.push(slugger.slug(text(node))); + if (node.type === 'html') for (const match of node.value.matchAll(/\bid=["']([^"']+)["']/gu)) result.push(match[1]); + }); + return result; +} +export { fragmentAliases } from './migration.mjs'; +/** AST rewriting keeps code, titles, reference definitions and nested Markdown intact. */ +export function projectMarkdown(raw, page, {root, ref, targets, assets = {}}) { + const tree = parser.parse(raw); + const h1s = tree.children.filter(node => node.type === 'heading' && node.depth === 1); + if (h1s.length !== 1 || text(h1s[0]) !== page.title) throw new Error(`${page.source}: one H1 matching the manifest title is required.`); + tree.children = tree.children.filter(node => node !== h1s[0]); + const links = [], fragments = []; + walk(tree, node => { + if (node.type === 'html' && /\n\n'+note,page)); + assert.doesNotThrow(()=>validateAvailability('# Title\n\n'+note+'>\n> Capability explanation in its own paragraph.\n',page)); + assert.throws(()=>validateAvailability('# Title\n\nBody first.\n\n'+note,page),/top-only/u); + assert.throws(()=>validateAvailability('# Title\n\n'+note+'\nText\n\n'+note,page),/top-only/u); + assert.throws(()=>validateAvailability('# Title\n\n'+note,{...page,availability:'current'}),/top-only/u); +}); +test('Selected fragments refuse a missing anchor rather than publishing a broken link',()=>{ + const reference={from:'source.md',source:'target.md',anchor:'section'}; + assert.doesNotThrow(()=>validateFragments([reference],{'target.md':['section']})); + assert.throws(()=>validateFragments([reference],{'target.md':['elsewhere']}),/target.md#section/u); +}); + +import {readFileSync, existsSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {validateProjection} from './migration.mjs'; +import {entries as legacyEntries} from '../website/docs-manifest.mjs'; +import {manualPages} from './manifest.mjs'; +import relatedQuestions from './related-questions.json' with {type:'json'}; +test('Every retained apex projection captures its actual old sections and maps to existing canonical headings',()=>{ + const state=JSON.parse(readFileSync(new URL('./authoring-state.json',import.meta.url),'utf8')); + const targetAnchors=Object.fromEntries(manualPages.map(page=>{ + const source=readFileSync(join(repositoryRoot,page.source),'utf8'); + const generatedQuestions=relatedQuestions[page.key]?.length?'\n## Related questions\n':''; + return [page.key,['_top',...anchorsOf(source+generatedQuestions)]]; + })); + for(const [route,selection] of Object.entries(state.routes)){ + const capture=selection.projection; + if(!capture)continue; + const entry=legacyEntries.find(entry=>(entry.route??(entry.slug?`/docs/${entry.slug}/`:'/docs/'))===route); + assert.ok(entry&&existsSync(join(repositoryRoot,entry.source)),route); + const original=execFileSync('git',['show',`${capture.fromRef}:${entry.source}`],{cwd:repositoryRoot,encoding:'utf8'}); + const body=original.startsWith('---\n')?original.replace(/^---\n[\s\S]*?\n---\n/u,''):original.replace(/^#\s+.+?\r?\n(?:\r?\n)?/u,''); + validateProjection(route,selection,{source:entry.source,digest:createHash('sha256').update(original).digest('hex'),anchors:['_top',...anchorsOf(body),...(entry.faq?.length?['related-questions']:[])],targetAnchors}); + } +}); diff --git a/apps/manual/search-index.mjs b/apps/manual/search-index.mjs new file mode 100644 index 00000000..258869f6 --- /dev/null +++ b/apps/manual/search-index.mjs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readdir, readFile, mkdir, cp, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { searchKind } from '../website/src/lib/search-kinds.mjs'; +import { origins } from './manifest.mjs'; +import { entries as legacyEntries } from '../website/docs-manifest.mjs'; +// Historical sources keep their finite route/source identity, not a current-search hit. +const historicalRoutes = new Set(legacyEntries.filter(entry=>entry.historical).map(entry=>entry.route ?? `/docs/${entry.slug}/`)); +const root = fileURLToPath(new URL('../../',import.meta.url)); +function checked(result,label) { if (result.errors?.length) throw new Error(`${label}: ${result.errors.join('; ')}`); return result; } +function escape(value) { return String(value).replace(/[&<>"']/gu,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[char]); } +async function htmlFiles(directory) { + const files = []; + for (const entry of await readdir(directory,{withFileTypes:true})) { + if (entry.isDirectory() && entry.name !== 'pagefind') files.push(...await htmlFiles(join(directory,entry.name))); + else if (entry.isFile() && entry.name.endsWith('.html')) files.push(join(directory,entry.name)); + } + return files.sort(); +} +export function indexable(path,html) { + return !path.startsWith('/docs/') && !path.startsWith('/downloads/') && !historicalRoutes.has(path) && !['/faq/','/source/','/privacy/','/impressum/','/terms/','/404/'].includes(path) && !path.startsWith('/__qualification/') && !/name=["']robots["'][^>]*content=["'][^"']*noindex/iu.test(html) && /\bdata-pagefind-body\b/u.test(html); +} +export function qualifyFaqRecords(records) { + const seen = new Set(); + for (const record of records) { + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(record.id) || seen.has(record.id) || record.url !== '/faq/#'+record.id || record.canonical !== origins.site+'/faq/#'+record.id || !record.title || !record.category || !record.html.includes('data-pagefind-body')) throw new Error('FAQ search records must preserve unique source identifiers, category, title and canonical fragment.'); + seen.add(record.id); + } + return records; +} +export async function buildSearch() { + const pagefind = await import('pagefind'); + const manifest = JSON.parse(await readFile(join(root,'artifacts/manual/manifest.json'),'utf8')); + const {index} = checked(await pagefind.createIndex({rootSelector:'[data-pagefind-body]'}),'Create joined index'); + if (!index) throw new Error('Pagefind returned no index.'); + const records = [], seen = new Set(); + const record = value => { + if (seen.has(value.url)) throw new Error(`Duplicate current search destination: ${value.url}`); + seen.add(value.url); records.push(value); + }; + try { + for (const [host,directory] of [['site',join(root,'artifacts/website/dist')],['docs',join(root,'artifacts/manual/dist')]]) { + for (const file of await htmlFiles(directory)) { + const path = '/'+relative(directory,file).replaceAll('\\','/').replace(/index\.html$/u,'').replace(/\.html$/u,'/'); + const html = await readFile(file,'utf8'); + if (!indexable(path,html)) continue; + if (host === 'docs' && !manifest.records.some(page=>page.route === path && page.current)) continue; + const url = origins[host]+path; + const kind = searchKind(url, { kind: host === 'docs' ? 'DOCS' : undefined }); + const metadata = `${kind}${escape(url)}`; + const content = html.replace(/(<[a-z][^>]*\bdata-pagefind-body(?:=["'][^"']*["'])?[^>]*>)/iu,'$1'+metadata); + checked(await index.addHTMLFile({url,content}),`Index ${url}`); record({url,kind}); + } + } + const faqFile = join(root,'artifacts/website/dist/faq/index.html'); + if (existsSync(faqFile)) { + if (!existsSync(join(root,'apps/website/faq-content.mjs')) || !existsSync(join(root,'docs/manual/faq.md'))) throw new Error('A built FAQ requires its separately admitted source and parser.'); + const {faqRecords} = await import(pathToFileURL(join(root,'apps/website/faq-content.mjs')).href); + for (const question of qualifyFaqRecords(faqRecords(await readFile(faqFile,'utf8')))) { + checked(await index.addHTMLFile({url:question.canonical,content:question.html}),`Index FAQ ${question.id}`); + record({url:question.canonical,kind:'FAQ',id:question.id}); + } + } + const output = join(root,'artifacts/public-search/pagefind'); + await rm(output,{recursive:true,force:true}); await mkdir(output,{recursive:true}); + checked(await index.writeFiles({outputPath:output}),'Write joined index'); + for (const host of ['website','manual']) { + const target = join(root,`artifacts/${host}/dist/pagefind`); await rm(target,{recursive:true,force:true}); await cp(output,target,{recursive:true}); + } + await writeFile(join(root,'artifacts/public-search/records.json'),JSON.stringify({ref:manifest.ref,records},null,2)+'\n'); + console.log(`Joined search: ${records.length} records; one index mirrored into both hosts.`); + } finally { await pagefind.close(); } + return records; +} +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) await buildSearch(); diff --git a/apps/manual/source-resolver.mjs b/apps/manual/source-resolver.mjs new file mode 100644 index 00000000..29de4ba5 --- /dev/null +++ b/apps/manual/source-resolver.mjs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { pages } from './manifest.mjs'; +import { migrations, migratedSource, isRetired } from './migration.mjs'; +export function sourceRef(root = fileURLToPath(new URL('../../', import.meta.url))) { + const ref = process.env.DIFFDEVIL_SOURCE_REF ?? execFileSync('git', ['rev-parse','HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + if (!/^[a-f0-9]{40}$/u.test(ref)) throw new Error('DIFFDEVIL_SOURCE_REF must be an exact commit SHA.'); + return ref; +} +export function editRef(root) { + const ref = process.env.DIFFDEVIL_EDIT_REF || process.env.GITHUB_HEAD_REF || execFileSync('git',['branch','--show-current'],{cwd:root,encoding:'utf8'}).trim() || 'main'; + if (!/^[a-zA-Z0-9_./-]+$/u.test(ref) || ref.includes('..')) throw new Error('Invalid edit branch.'); + return ref; +} +export const repositorySources = Object.freeze([ + 'docs/language/parser-architecture.md','src/diffdevil/contracts/detail/v1/README.md', + 'README.md','docs/README.md','docs/VISION.md','docs/DIRECTION.md','docs/DECISIONS.md', + 'docs/ARCHITECTURE.md','docs/DEVELOPMENT.md','docs/PROJECT-MAP.md','docs/qualification.md', + 'docs/publication-boundary.md','docs/documentation.md','docs/presentation.md', + 'docs/integration/github-api.md','docs/integration/github-app.md','docs/integration/action-distribution.md', + 'docs/integration/example-catalogue.md','docs/PRIVACY-AND-DATA.md','SECURITY.md','LICENSES/README.md', + 'apps/manual/README.md','apps/browser-extension/README.md','apps/browser-extension/privacy.md','docs/releases/v1.0.0.md', + 'apps/playground/README.md','apps/github-app/README.md','apps/website/README.md', + 'skills/diffdevil/SKILL.md','skills/diffdevil/references/install-and-update.md', + 'skills/diffdevil/references/restricted-harnesses.md','skills/diffdevil/references/cli-and-evidence.md', +]); +// These previously selected IDs survive the repository's filename-only migration. +// The destination uses Git's exact casing, independent of the build filesystem. +export const sourceAliases = Object.freeze({ + 'docs/QUALIFICATION.md':'docs/qualification.md', + 'docs/PUBLICATION-BOUNDARY.md':'docs/publication-boundary.md', + 'docs/DOCUMENTATION.md':'docs/documentation.md', + 'docs/PRESENTATION.md':'docs/presentation.md', + 'apps/browser-extension/PRIVACY.md':'apps/browser-extension/privacy.md', +}); +export function sourceUrl(source,ref) { + if (!/^[a-f0-9]{40}$/u.test(ref)) throw new Error('Source provenance needs an exact commit SHA.'); + return `https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${source.split('/').map(encodeURIComponent).join('/')}`; +} +/** The resolver receives this finite generated map, never a repository path template. + * @param {{ref: string, state: object, exists: (source: string) => boolean, legacySources?: string[]}} options + * @returns {Record}>} + */ +export function sourceTargets({ ref, state, exists, legacySources = [] }) { + const targets = Object.create(null); + const selected = new Set([...pages.map(page=>page.sourceId),...repositorySources, + ...migrations.map(row=>row.source),...legacySources,...Object.keys(sourceAliases),...Object.values(sourceAliases)]); + for (const id of selected) { + const canonical = Object.hasOwn(sourceAliases,id) ? sourceAliases[id] : id; + const source = migratedSource(canonical,state) ?? canonical; + if (!exists(source)) continue; + targets[id] = { url: sourceUrl(source,ref), fragments: Object.create(null) }; + if (isRetired(canonical,state)) for (const [old,destination] of Object.entries(state.transfers[canonical].anchors ?? {})) { + const page = pages.find(item=>item.key===destination.page); + targets[id].fragments[old] = `${sourceUrl(page.source,ref)}#${encodeURIComponent(destination.anchor)}`; + } + } + return targets; +} +export { resolveSource, sourceResolverUrl } from '../website/src/lib/source-resolution.mjs'; diff --git a/apps/manual/source-resolver.test.mjs b/apps/manual/source-resolver.test.mjs new file mode 100644 index 00000000..f971fd44 --- /dev/null +++ b/apps/manual/source-resolver.test.mjs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { sourceAliases, repositorySources, sourceTargets, resolveSource } from './source-resolver.mjs'; +const ref = '1'.repeat(40); + +test('stable source IDs resolve to the exact renamed Git path on either filesystem', () => { + const paths = new Set(Object.values(sourceAliases)); + const targets = sourceTargets({ref,state:{},exists:path=>paths.has(path)}); + for (const [old,current] of Object.entries(sourceAliases)) { + const expected = `https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${current}#section`; + for (const id of [old,current]) assert.equal(resolveSource('/source/?f='+encodeURIComponent(id)+'#section',targets),expected); + } + assert.equal(resolveSource('/source/?f=docs/Qualification.md',targets),null); + assert.equal(resolveSource('/source/?f=docs/../../secret',targets),null); +}); +test('maintained resolver destinations exist at their selected repository paths', () => { + for (const source of new Set([...repositorySources,...Object.values(sourceAliases)])) { + assert.ok(existsSync(new URL('../../'+source,import.meta.url)),`Missing maintained resolver destination: ${source}`); + } +}); + +import { bindSourceSelection } from '../website/src/lib/source-resolution.mjs'; +test('source links follow fragments and history, and refusal removes a previous destination', () => { + const view = new EventTarget(); + view.location = {href: 'https://diffdevil.dev/source/?f=old#first'}; + const attributes = new Map(); + const link = {hidden: true, setAttribute: (key,value) => attributes.set(key,value), removeAttribute: key => attributes.delete(key), focus() {this.focused = true;}}; + const status = {textContent: ''}; + const first = 'https://github.com/example/repo/blob/ref/start.md#first'; + const second = 'https://github.com/example/repo/blob/ref/use.md#second'; + const targets = {old: {url: first, fragments: {first, second}}}; + const dispose = bindSourceSelection({view, targets, link, status}); + assert.equal(attributes.get('href'), first); assert.equal(link.hidden, false); assert.equal(link.focused, true); + view.location.href = 'https://diffdevil.dev/source/?f=old#second'; + view.dispatchEvent(new Event('hashchange')); + assert.equal(attributes.get('href'), second); + view.location.href = 'https://diffdevil.dev/source/?f=old#first'; + view.dispatchEvent(new Event('popstate')); + assert.equal(attributes.get('href'), first); + for (const url of ['https://diffdevil.dev/source/?f=old#%E0%A4%A', 'https://diffdevil.dev/source/?f=old&f=another']) { + view.location.href = url; view.dispatchEvent(new Event('popstate')); + assert.equal(link.hidden, true); assert.equal(attributes.has('href'), false); + assert.equal(status.textContent, 'No recognized source was selected.'); + } + view.location.href = 'https://diffdevil.dev/source/?f=old#second'; + view.dispatchEvent(new Event('popstate')); + assert.equal(attributes.get('href'), second); assert.equal(link.hidden, false); + dispose(); view.location.href = 'https://diffdevil.dev/source/?f=old#first'; + view.dispatchEvent(new Event('hashchange')); view.dispatchEvent(new Event('popstate')); + assert.equal(attributes.get('href'), second); +}); diff --git a/apps/manual/src/components/Footer.astro b/apps/manual/src/components/Footer.astro new file mode 100644 index 00000000..0dcfe95f --- /dev/null +++ b/apps/manual/src/components/Footer.astro @@ -0,0 +1,35 @@ +--- +// SPDX-License-Identifier: AGPL-3.0-only +import Pagination from '@astrojs/starlight/components/Pagination.astro'; +import SiteFooter from '../../../website/src/components/Footer.astro'; +import fragments from '../generated/fragments.json'; +const { entry } = Astro.locals.starlightRoute; +const meta = entry.data.diffdevil; +const aliases = meta ? fragments[meta.key as keyof typeof fragments] ?? {} : {}; +--- + + + + + diff --git a/apps/manual/src/content.config.ts b/apps/manual/src/content.config.ts new file mode 100644 index 00000000..0ae9d918 --- /dev/null +++ b/apps/manual/src/content.config.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { defineCollection } from 'astro:content'; +import { z } from 'astro/zod'; +import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders'; +import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; +export const collections = { + i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }), + docs: defineCollection({ + loader: docsLoader(), + schema: docsSchema({ extend: z.object({ + diffdevil: z.object({ + key: z.string(), source: z.string(), sourceUrl: z.string(), sourceRef: z.string(), + packageVersion: z.string(), availability: z.enum(['current','in-development']), + }), + }) }), + }), +}; diff --git a/apps/manual/src/content/i18n/en.json b/apps/manual/src/content/i18n/en.json new file mode 100644 index 00000000..2e27f1a7 --- /dev/null +++ b/apps/manual/src/content/i18n/en.json @@ -0,0 +1,3 @@ +{ + "sidebarNav.accessibleLabel": "Manual navigation" +} diff --git a/apps/manual/src/styles/reading.css b/apps/manual/src/styles/reading.css new file mode 100644 index 00000000..c9cf7674 --- /dev/null +++ b/apps/manual/src/styles/reading.css @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Reading grammar over the existing Works shell; starlight-works owns navigation/alerts. */ +:root { --sl-text-body:16px; --sl-line-height:1.7; --sl-content-width:48rem; --slw-sidebar-current-bg:var(--bg-raised-2); --slw-sidebar-current-text:var(--fg); --slw-sidebar-focus:var(--accent-text); --slw-alert-radius:.4rem; --slw-alert-padding:.75rem 1rem; } +.sl-markdown-content { font-size:1rem; line-height:1.7; overflow-wrap:anywhere; } +.sl-markdown-content > :first-child { margin-block-start:0; } +.sl-markdown-content h2 { font-size:1.4rem; line-height:1.3; margin-block-start:2.1em; } +.sl-markdown-content h3 { font-size:1.1rem; line-height:1.4; } +.sl-markdown-content :is(pre,table) { max-width:100%; overflow-x:auto; } +.sl-markdown-content pre { overflow-wrap:normal; white-space:pre; } +.sl-markdown-content table { display:block; width:max-content; max-width:100%; font-size:.875rem; } +.sl-markdown-content :is(td,th) { min-width:4rem; max-width:28rem; white-space:normal; } +.sl-markdown-content .slw-alert { font-size:.9375rem; } +.sl-markdown-content .slw-alert__title { font-size:.875rem; } +.sl-markdown-content details { border-block:1px solid var(--hair); padding-block:.75rem; } +.sl-markdown-content summary { cursor:pointer; font-weight:600; } +:is(.main-pane,.content-panel,.sl-container,.sl-markdown-content) { min-width:0; } +.docs-provenance a { overflow-wrap:anywhere; } +.slw-category-link { text-transform:none; letter-spacing:normal; } +:focus-visible { outline:2px solid var(--accent-text); outline-offset:3px; } +.fragment-compatibility { margin-block:1rem; font-size:.875rem; } +@media (max-width:49.99rem) { .sl-markdown-content { font-size:1rem; } } +@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; animation:none!important; } } +@media (forced-colors:active) { .slw-alert { border:1px solid CanvasText; } :focus-visible { outline-color:Highlight; } .slw-category-link[aria-current="page"] { outline:1px solid Highlight; } } + +/* The shared footer sits inside the article, not a full-width landing page. */ +.docs-site-footer { container-type:inline-size; } +.docs-site-footer .site-footer .grid { grid-template-columns:repeat(2,minmax(0,1fr)); } +.docs-site-footer .site-footer .footer-row { white-space:normal; } +.docs-site-footer .site-footer a { overflow-wrap:anywhere; } +@container (max-width:32rem) { + .docs-site-footer .site-footer .grid { grid-template-columns:minmax(0,1fr); } +} diff --git a/apps/manual/static-handler.mjs b/apps/manual/static-handler.mjs new file mode 100644 index 00000000..0f3c56d6 --- /dev/null +++ b/apps/manual/static-handler.mjs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Cloudflare Pages advanced-mode handler. Route identity comes from the selected + * build, never a source path or an incoming destination parameter. The browser + * carries fragments across HTTP redirects; the handler preserves path and query. + */ +export function createStaticHandler({ site, docs, compatibility, host, redirects }) { + const canonical = host === 'site' ? site : docs; + const selected = new Map(redirects.map(({ from, to }) => [from, to])); + return { + async fetch(request, env) { + const url = new URL(request.url); + if (url.hostname === new URL(compatibility).hostname) { + return Response.redirect(site + url.pathname + url.search, 308); + } + const destination = selected.get(url.pathname); + if (destination && url.hostname === new URL(canonical).hostname) { + const target = new URL(destination); + target.search = url.search; + return Response.redirect(target.href, 308); + } + return env.ASSETS.fetch(request); + }, + }; +} diff --git a/apps/manual/tsconfig.json b/apps/manual/tsconfig.json new file mode 100644 index 00000000..43a445d7 --- /dev/null +++ b/apps/manual/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["node_modules", "../../artifacts"] +} diff --git a/apps/manual/wave-1.test.mjs b/apps/manual/wave-1.test.mjs new file mode 100644 index 00000000..8784de34 --- /dev/null +++ b/apps/manual/wave-1.test.mjs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Executable first-success contracts, not phrase-locked narrative. */ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +import { analyzeDiff, unwrap } from '../../dist/lib/index.js'; +import { compilePolicy, evaluatePolicy, readPolicyYaml, createPlan } from '../../dist/lib/policy/index.js'; +import { generatedIsland } from './generated-islands.mjs'; +const root = resolve(import.meta.dirname, '../..'); +const read = path => readFileSync(join(root, path), 'utf8'); +const json = path => JSON.parse(read(path)); +const home = mkdtempSync(join(tmpdir(), 'diffdevil wave one ')); +after(() => rmSync(home, { recursive: true, force: true })); +const entry = join(root, 'dist/lib/cli/main.js'); +function cli(args, status = 0, cwd = root) { + const result = spawnSync(process.execPath, [entry, ...args], { cwd, encoding: 'utf8', windowsHide: true, env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } }); + assert.ifError(result.error); + assert.equal(result.status, status, `${args.join(' ')}\n${result.stdout}\n${result.stderr}`); + return result.stdout; +} +const policy = document => unwrap(compilePolicy(unwrap(readPolicyYaml(JSON.stringify(document))))); +const small = unwrap(analyzeDiff(read('docs/examples/diffs/review.diff'))); +const evaluated = unwrap(evaluatePolicy(policy({ version: 1, presets: ['size@1'] }), small)); +writeFileSync(join(home, 'report.json'), JSON.stringify(evaluated.report)); +const bundled = join(home, 'production.mjs'); +await build({ stdin: { contents: [ + "export { exportCli } from './apps/website/src/islands/playground/export-cli.ts';", + "export { readState } from './apps/website/src/islands/playground/state.ts';", + "export { personalYaml, validateSettings, decorateView } from './apps/browser-extension/src/shared/settings.ts';", + "export { humanReport, compileBrowserPolicy } from './dist/browser/index.js';", +].join('\n'), resolveDir: root }, outfile: bundled, bundle: true, platform: 'node', format: 'esm', target: 'node22', alias: { '@wolfsblvt/diffdevil/browser': join(root, 'dist/browser/index.js') } }); +const production = await import(pathToFileURL(bundled).href); + +test('The complete introductory replacement and review specimens keep their separate accounting', () => { + const replacement = unwrap(analyzeDiff(read('docs/examples/diffs/replacement.diff'))); + assert.equal(replacement.totals.lines.changed.value, 3); + assert.equal(replacement.totals.raw.churn.value, 6); + assert.equal(small.totals.lines.changed.value, 10); + assert.equal(small.totals.raw.churn.value, 16); + assert.equal(small.files.length, 4); + assert.equal(evaluated.report.bands.size.id, 'xs'); + const selected = unwrap(evaluatePolicy(policy({ version: 1, presets: ['size@1'], defaults: { paths: { exclude: ['package-lock.json'] } } }), small)); + assert.equal(selected.report.totals.lines.changed.value, 6); + assert.equal(selected.report.totals.raw.churn.value, 8); + assert.equal(selected.report.files.filter(file => file.included).length, 3); +}); + +test('Copyable complete workflow, hold policy and shell consumers match their canonical assets', () => { + for (const [page, asset, language] of [ + ['start/label-pull-requests.md', 'workflows/size.yml', 'yaml'], + ['understand/facts-to-provider-state.md', 'policies/hold-unresolved.yml', 'yaml'], + ['start/use-results-in-scripts.md', 'scripts/report-consumer.sh', 'bash'], + ['start/use-results-in-scripts.md', 'scripts/report-consumer.ps1', 'powershell'], + ]) { + const blocks = [...read('docs/manual/' + page).matchAll(/```([^\n]*)\n([\s\S]*?)\n```/gu)]; + assert.ok(blocks.some(([, kind, text]) => kind === language && text === read('docs/examples/' + asset).trimEnd()), asset); + } +}); + +test('CLI decisions preserve true, false, invalid and unresolved instead of fabricating scalars', () => { + const source = ['--report', 'docs/examples/reports/bounded.json']; + cli(['check', ...source, '--expr', 'totals.lines.changed < 100'], 0); + cli(['check', ...source, '--expr', 'totals.lines.changed < 60'], 1); + cli(['check', ...source, '--expr', 'totals.lines.changed >= 65'], 3); + cli(['check', ...source, '--expr', 'not a valid expression'], 2); + assert.equal(cli(['query', ...source, '--metric', 'changed', '--format', 'value'], 3), ''); + const output = join(home, 'strict-output'); writeFileSync(output, 'preserved'); + cli(['query', ...source, '--metric', 'changed', '--format', 'value', '--output', output], 3); + assert.equal(readFileSync(output, 'utf8'), 'preserved'); + const result = JSON.parse(cli(['query', ...source, '--metric', 'changed', '--format', 'json'])); + assert.deepEqual(result.value.measurement, { status: 'bounded', lower: 60, upper: 70 }); +}); + +test('Incomplete path membership refuses a complete list; certain deliberately selects the observed subset', () => { + const args = ['query', '--report', 'docs/examples/reports/incomplete.json', '--files', '--select', 'path', '--format', 'nul']; + assert.equal(cli(args, 3), ''); + assert.equal(cli([...args, '--certain']), 'src/Observed.cs\0'); +}); + +test('A held custom rule and the default Unknown label are different outcomes', () => { + const args = ['plan', '--report', 'docs/examples/reports/bounded.json', '--config', 'docs/examples/policies/hold-unresolved.yml', '--target-repo', 'example/repository', '--target-pr', '42', '--format', 'json']; + const held = JSON.parse(cli(args)); + assert.equal(held.stage, 'desired'); assert.equal(held.held.length, 1); assert.deepEqual(held.operations, []); + assert.equal(JSON.parse(cli([...args, '--require-resolved'], 3)).held.length, 1); + const unknown = JSON.parse(cli(['plan', '--report', 'docs/examples/reports/incomplete.json', '--no-config', '--preset', 'size@1', '--target-repo', 'example/repository', '--target-pr', '43', '--format', 'json'])); + assert.deepEqual(unknown.held, []); + assert.ok(unknown.operations.some(operation => operation.selected === 'size/Unknown')); +}); + +test('Generated catalogue links select actual example and variant identities in the production URL reader', () => { + const rendered = generatedIsland('real-pr-catalogue', { root, ref: '1'.repeat(40) }); + const links = [...rendered.matchAll(/\]\((https:\/\/diffdevil.dev\/playground\/[^)]+)\)/gu)]; + const entries = json('docs/examples/catalogue/catalogue.json').entries; + assert.equal(links.length, 7); + for (const [, href] of links) { + const state = production.readState(new URL(href).search, entries[0].id); + const entry = entries.find(candidate => candidate.id === state.example); + assert.ok(entry, href); assert.ok(entry.variants.some(variant => variant.id === state.variant), href); + assert.equal(state.head, 'snapshot'); + } +}); + +test('The Playground exported commands replay the displayed exclusion report and current policy', () => { + const snapshot = json('docs/examples/catalogue/snapshots/vite-18968.json'); + const policies = json('docs/examples/catalogue/policies.json').policies; + for (const [name, changed, churn, band] of [['standard', 22, 25, 's'], ['vite-without-lockfile', 1, 2, 'xs']]) { + const applied = unwrap(evaluatePolicy(policy(policies[name]), snapshot.report)); + assert.equal(applied.report.totals.lines.changed.value, changed); + assert.equal(applied.report.totals.raw.churn.value, churn); + assert.equal(applied.report.bands.size.id, band); + writeFileSync(join(home, 'report.json'), JSON.stringify(applied.report)); + writeFileSync(join(home, '.diffdevil.yml'), JSON.stringify(policies[name])); + const text = production.exportCli(name === 'standard', { repository: 'vitejs/vite', pullRequest: 18968 }); + const commands = text.split('\n').filter(line => !line.startsWith('#')); + assert.equal(commands.length, 3); + for (const line of commands) { + const args = line.split(' ').slice(2); + assert.ok(['analyze', 'query', 'plan'].includes(args[0])); + assert.ok(args.includes('--report')); assert.ok(!args.includes('--base')); + const output = cli(args, 0, home); + if (args[0] === 'query') assert.equal(output, `${changed}\n`); + } + const desired = unwrap(createPlan(applied, { repository: 'vitejs/vite', pullRequest: 18968 })); + assert.equal(desired.stage, 'desired'); + } +}); + +test('The admitted extension presentation reads the same canonical 10 Changed / 16 churn specimen', () => { + const settings = production.validateSettings({}); + const compiled = unwrap(production.compileBrowserPolicy(JSON.stringify({ mode: 'personal-only', personal: production.personalYaml(settings) }))); + const view = unwrap(production.humanReport(small, compiled)); + assert.equal(view.changed.value, 10); assert.equal(view.raw.churn.value, 16); + assert.equal(view.report.files.length, 4); + assert.equal(view.rails[0].selected, 'xs'); +}); + +test('The first desired-plan insert is the shared presenter of the small specimen, not the 178-line fixture', () => { + const insert = generatedIsland('presenter-first-plan', { root, ref: '1'.repeat(40) }); + const actual = cli(['plan', '--diff-file', 'docs/examples/diffs/review.diff', '--no-config', '--target-repo', 'example/repository', '--target-pr', '42', '--format', 'human', '--color', 'never']).trimEnd(); + assert.equal(insert, '```text\n' + actual + '\n```'); +}); diff --git a/apps/manual/wave-2.test.mjs b/apps/manual/wave-2.test.mjs new file mode 100644 index 00000000..7c43ff6c --- /dev/null +++ b/apps/manual/wave-2.test.mjs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Surface examples exercise public operations; prose remains reader-authored. */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync,writeFileSync,mkdtempSync,rmSync,existsSync} from 'node:fs'; +import {join,resolve} from 'node:path'; +import {tmpdir} from 'node:os'; +import {spawnSync} from 'node:child_process'; +import {parse} from 'yaml'; +import {unwrap,analyzeDiff} from '../../dist/lib/index.js'; +import {compilePolicy,readPolicyYaml,evaluatePolicy,createPlan,readPlan} from '../../dist/lib/policy/index.js'; +import {GitHubClient} from '../../dist/lib/github/client.js'; +import {runAction} from '../../dist/lib/actions/run.js'; +import {FakeGitHub,TARGET} from '../../src/diffdevil/tests/helpers/github.mjs'; +import {pages,sidebar} from './manifest.mjs'; +import {isRetired,legacyRedirects,pageIsCurrent} from './migration.mjs'; +import {sourceTargets,resolveSource} from './source-resolver.mjs'; +const root=resolve(import.meta.dirname,'../..'); +const read=path=>readFileSync(join(root,path),'utf8'); +const state=JSON.parse(read('apps/manual/authoring-state.json')); +const blocks=path=>[...read(path).matchAll(/```([^\n]*)\n([\s\S]*?)\n```/gu)]; +function workspace(t){const home=mkdtempSync(join(tmpdir(),'diffdevil surface examples '));t.after(()=>rmSync(home,{recursive:true,force:true}));return home;} +function command(line,home,expected=0){ + const tokens=[...line.matchAll(/'([^']*)'|"([^"]*)"|(\S+)/gu)].map(m=>m[1]??m[2]??m[3]); + assert.deepEqual(tokens.slice(0,4),['npm','exec','--','diffdevil']); + const args=tokens.slice(4).map(value=>value.startsWith('node_modules/@wolfsblvt/diffdevil/')?join(root,value.slice('node_modules/@wolfsblvt/diffdevil/'.length)):value); + assert.ok(!['apply','labels'].includes(args[0]),'Provider examples are exercised only through fixture HTTP.'); + const result=spawnSync(process.execPath,[join(root,'dist/lib/cli/main.js'),...args],{cwd:home,encoding:'utf8',windowsHide:true,env:{...process.env,NO_COLOR:'1'}}); + assert.ifError(result.error);assert.equal(result.status,expected,line+'\n'+result.stderr);return result.stdout; +} + +test('complete surface workflows, policies and library program equal their canonical files',()=>{ + for(const [page,asset,language] of [ + ['github-actions.md','workflows/size.yml','yaml'], + ['github-actions.md','workflows/analyze.yml','yaml'], + ['github-actions.md','workflows/analyze-and-apply.yml','yaml'], + ['shared-workflows/labels-comments-and-definitions.md','policies/review-signals.yml','yaml'], + ['shared-workflows/labels-comments-and-definitions.md','policies/review-comment.yml','yaml'], + ['typescript-library.md','library/inspect-change.mts','typescript'], + ]) assert.ok(blocks('docs/manual/use/'+page).some(([,kind,body])=>kind===language&&body===read('docs/examples/'+asset).trimEnd()),asset); +}); + +test('the report workflow executes capture, replay, re-policy, desired planning and a held return',t=>{ + const home=workspace(t),groups=blocks('docs/manual/use/shared-workflows/reports-plans-and-apply.md').filter(([,kind])=>kind==='sh').map(([, ,body])=>body.split('\n')); + const capture=groups.find(lines=>lines[0].includes('--output report.json')); + assert.ok(capture);for(const line of capture)command(line,home); + const report=JSON.parse(readFileSync(join(home,'report.json'),'utf8')); + assert.equal(report.totals.lines.changed.value,10);assert.equal(report.totals.raw.churn.value,16); + assert.equal(report.bands.size.id,'xs'); + for(const line of groups.find(lines=>lines[0].includes('--expr totals.raw.churn')))command(line,home); + command(groups.find(lines=>lines[0].includes('--output selected-report.json'))[0],home); + const selected=JSON.parse(readFileSync(join(home,'selected-report.json'),'utf8')); + assert.equal(selected.totals.lines.changed.value,6);assert.equal(selected.totals.raw.churn.value,8); + assert.equal(selected.metrics.sourceReview.value,3);assert.equal(selected.metrics.testsReview.value,2); + for(const line of groups.find(lines=>lines[0].includes('--output desired.json')))command(line,home); + const plan=unwrap(readPlan(readFileSync(join(home,'desired.json'),'utf8'))); + assert.equal(plan.stage,'desired');assert.ok(plan.operations.some(op=>op.kind==='label.select'&&op.selected==='size/XS')); + command(groups.find(lines=>lines[0].includes('--output held.json'))[0],home,3); + const held=unwrap(readPlan(readFileSync(join(home,'held.json'),'utf8'))); + assert.equal(held.held.length,1);assert.deepEqual(held.operations,[]); +}); + +test('the two-step Action consumes the full runner report, reacquires it and refuses source drift',async t=>{ + const home=workspace(t),workflow=parse(read('docs/examples/workflows/analyze-and-apply.yml')); + const [first,second]=workflow.jobs.size.steps; + assert.equal(first.uses,'Wolfsblvt/diffdevil/actions/analyze@v1'); + assert.equal(second.with['input-report'],'${{ steps.measured.outputs.report-path }}'); + assert.equal(workflow.permissions['pull-requests'],'write'); + assert.ok(workflow.on.pull_request_target);assert.equal(workflow.concurrency['cancel-in-progress'],false); + const fake=new FakeGitHub(),client=new GitHubClient({fetch:fake.fetch,readRetries:0}); + const environment={GITHUB_WORKSPACE:home,RUNNER_TEMP:home,GITHUB_REPOSITORY:TARGET.repository,GITHUB_EVENT_NAME:'pull_request_target',GITHUB_EVENT_PATH:join(home,'event.json'),GITHUB_OUTPUT:join(home,'output'),GITHUB_STEP_SUMMARY:join(home,'summary')}; + writeFileSync(environment.GITHUB_EVENT_PATH,JSON.stringify({repository:{full_name:TARGET.repository},pull_request:{number:42}})); + writeFileSync(environment.GITHUB_OUTPUT,'');writeFileSync(environment.GITHUB_STEP_SUMMARY,''); + const measured=unwrap(await runAction('analyze',{environment,client})); + assert.equal(measured.exitCode,0);assert.equal(fake.writes().length,0); + assert.ok(existsSync(measured.outputs['report-path'])); + assert.equal(JSON.parse(readFileSync(measured.outputs['report-path'],'utf8')).kind,'diffdevil.report'); + const calls=fake.calls.length; + const applying={...environment,'INPUT_INPUT-REPORT':measured.outputs['report-path']}; + assert.equal(unwrap(await runAction('apply',{environment:applying,client})).exitCode,0); + assert.ok(fake.calls.slice(calls).some(call=>call.path.endsWith('/pulls/42/files')),'Apply must reacquire, not trust its same-job file.'); + assert.ok(fake.labels.has('size/XS'));assert.equal(fake.comments.length,0); + const writes=fake.writes().length; + fake.head='d'.repeat(40); + const stale=await runAction('apply',{environment:applying,client}); + assert.ok(!stale.ok||stale.value.exitCode!==0);assert.equal(fake.writes().length,writes); +}); + +test('label and comment teaching policies retain false-removal, optional comments and exact shared facts',()=>{ + const report=unwrap(analyzeDiff(read('docs/examples/diffs/review.diff'))); + const evaluate=name=>unwrap(evaluatePolicy(unwrap(compilePolicy(unwrap(readPolicyYaml(read('docs/examples/policies/'+name+'.yml'))))),report)); + const labels=unwrap(createPlan(evaluate('review-signals'),TARGET,{definitions:'ensure'})); + assert.ok(labels.operations.some(op=>op.kind==='label.remove'&&op.name==='review/source-without-tests')); + assert.equal(labels.operations.some(op=>op.kind==='comment.reconcile'),false); + const comments=unwrap(createPlan(evaluate('review-comment'),TARGET)); + assert.equal(comments.operations.length,1);assert.equal(comments.operations[0].kind,'comment.reconcile'); + assert.match(comments.operations[0].body,/changed lines: 10\nRaw churn: 16\nDeleted-only or modified lines: 6\nIncluded files: 4/u); +}); + +test('accepted Wave 2 transfers survive later completed source waves',()=>{ + for(const page of pages.filter(page=>page.wave===2))assert.equal(state.pages[page.key].status,'authored',page.key); + const ref='1'.repeat(40),targets=sourceTargets({ref,state,exists:path=>existsSync(join(root,path))}); + for(const source of ['docs/guides/auto-label-pull-requests.md','docs/guides/local-automation.md','docs/integration/playground.md']){ + assert.equal(isRetired(source,state),true);assert.equal(existsSync(join(root,source)),false); + for(const [old,destination] of Object.entries(state.transfers[source].anchors)){ + const page=pages.find(page=>page.key===destination.page); + assert.equal(resolveSource('/source/?f='+encodeURIComponent(source)+'#'+old,targets),`https://github.com/Wolfsblvt/diffdevil/blob/${ref}/${page.source}#${destination.anchor}`); + } + } + assert.ok(legacyRedirects(state).some(rule=>rule.from==='/docs/get-started/auto-label-pull-requests/')); + assert.ok(legacyRedirects(state).some(rule=>rule.from==='/docs/get-started/local-automation/')); + assert.ok(legacyRedirects(state).some(rule=>rule.from==='/docs/playground/')); + assert.equal(pageIsCurrent('playground',state),true);assert.equal(pageIsCurrent('cli',state),true); +}); + +test('extension navigation, selected assets and canonical Skill sources have separate identities',()=>{ + const leaves=[];function walk(items){for(const item of items){leaves.push(item);walk(item.items??[]);}}walk(sidebar()); + const page=pages.find(page=>page.key==='browser-extension');assert.equal(page.title,'diffdevil for GitHub'); + assert.ok(leaves.some(item=>item.label==='Browser extension'&&item.slug==='use/browser-extension')); + const assets=JSON.parse(read('apps/manual/assets.json')); + assert.equal(assets['apps/website/src/assets/extension/settings-policy-bands-light.png'],'/assets/manual/settings-policy-bands-light.png'); + assert.ok(existsSync(join(root,'skills/diffdevil/references/install-and-update.md'))); + const source=read('apps/website/src/lib/sources.ts');assert.match(source,/skill: 'docs\/setup\/skill\.md'/u); + // Public install snippets select the registry route, not a stale fixed patch release. + const installSources=[...pages.filter(page=>page.wave===1||page.wave===2).map(page=>page.source),'docs/setup/cli.md']; + for(const source of installSources) + for(const [,kind,body] of blocks(source))if(['sh','bash','powershell'].includes(kind))assert.doesNotMatch(body,/@wolfsblvt\/diffdevil@\d/u,source); +}); diff --git a/apps/manual/wave-3.test.mjs b/apps/manual/wave-3.test.mjs new file mode 100644 index 00000000..c28c1d38 --- /dev/null +++ b/apps/manual/wave-3.test.mjs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** Executable policy chapters and mechanically derived reference boundaries. */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { parse } from 'yaml'; +import { analyzeDiff, readReport, readPlan, validateSchema, unwrap, sourcePosition } from '../../dist/lib/index.js'; +import { compilePolicy, readPolicyYaml, evaluatePolicy, evaluatePolicyQuery, createPlan } from '../../dist/lib/policy/index.js'; +import { pages } from './manifest.mjs'; +import { schemaFields, generatedIsland } from './generated-islands.mjs'; +import { committedIslands, projectIslands, updateInventories } from './generated-content.mjs'; +import { sourceTargets, resolveSource } from './source-resolver.mjs'; +import { isRetired, pageIsCurrent } from './migration.mjs'; + +const root=resolve(import.meta.dirname,'../..'); +const read=path=>readFileSync(join(root,path),'utf8'); +const wave=pages.filter(page=>page.wave===3); +const state=JSON.parse(read('apps/manual/authoring-state.json')); +const small=unwrap(analyzeDiff(read('docs/examples/diffs/review.diff'))); +const blocks=source=>[...read(source).matchAll(/```([^\n]*)\n([\s\S]*?)\n```/gu)]; +const policy=name=>unwrap(compilePolicy(unwrap(readPolicyYaml(read(`docs/examples/policies/story/${name}.yml`),{name:name+'.yml'})))); + +test('each complete policy chapter runs independently over the same small comparison',()=>{ + for(const name of ['preset','configure','paths','rules','labels','comments']) { + const compiled=policy(name),result=unwrap(evaluatePolicy(compiled,small)); + const selected=!['preset','configure'].includes(name); + assert.equal(result.report.totals.lines.changed.value,selected?6:10,name); + assert.equal(result.report.totals.raw.churn.value,selected?8:16,name); + assert.equal(result.report.bands.size.id,name==='configure'?'s':'xs',name); + assert.equal(result.report.files.length,4,'Exclusion must not delete an observed file.'); + if (['rules','labels','comments'].includes(name)) { + assert.equal(result.report.metrics.sourceReview.value,3); + assert.equal(result.report.metrics.testReview.value,2); + assert.equal(result.report.metrics.attention.value,5); + assert.equal(result.report.bands.attention.id,'substantial'); + const falseCheck=unwrap(evaluatePolicyQuery(compiled,small,'metrics.attention >= params.attentionAt',{context:'condition',parameters:{attentionAt:6}})); + assert.equal(falseCheck.result.value.decision.value,false); + } + const plan=unwrap(createPlan(result,{repository:'example/repository',pullRequest:42},{definitions:'ensure'})); + assert.equal(plan.stage,'desired');assert.deepEqual(plan.held,[]); + assert.equal(plan.operations.some(op=>op.kind==='label.add'&&op.name==='review/payments'),['labels','comments'].includes(name)); + const comments=plan.operations.filter(op=>op.kind==='comment.reconcile'); + assert.equal(comments.length,name==='comments'?1:0); + if(comments.length) assert.match(comments[0].body,/Source Changed: 3\nTests Changed: 2\nIncluded Changed: 6\nIncluded raw churn: 8/u); + assert.equal(readPlan(JSON.stringify(plan)).ok,true); + } +}); + +test('copyable policies, workflows and TypeScript consumer are complete canonical files',()=>{ + for(const name of ['preset','configure','paths','rules','labels','comments']) { + const expected=read(`docs/examples/policies/story/${name}.yml`).trimEnd(); + assert.ok(wave.some(page=>blocks(page.source).some(([,language,body])=>language==='yaml'&&body===expected)),name); + } + for(const [asset,page,language] of [ + ['workflows/size.yml','reference/github-actions.md','yaml'], + ['workflows/analyze.yml','reference/github-actions.md','yaml'], + ['library/inspect-change.mts','reference/typescript-api.md','typescript'], + ]) assert.ok(blocks('docs/manual/'+page).some(([,kind,body])=>kind===language&&body===read('docs/examples/'+asset).trimEnd()),asset); +}); + +test('all displayed read-only CLI invocations execute, including the intentional false check',t=>{ + const home=mkdtempSync(join(tmpdir(),'diffdevil policy reference '));t.after(()=>rmSync(home,{recursive:true,force:true})); + let executed=0; + for(const page of wave) for(const [,kind,body] of blocks(page.source)) { + if(kind!=='sh')continue; + for(const line of body.split('\n').filter(line=>line.startsWith('npx diffdevil '))) { + const tokens=[...line.matchAll(/'([^']*)'|"([^"]*)"|(\S+)/gu)].map(m=>m[1]??m[2]??m[3]); + const args=tokens.slice(2).map(arg=>arg.startsWith('docs/examples/')?join(root,arg):arg); + assert.ok(!['apply','labels'].includes(args[0]),'Provider mutation must not enter an offline documentation check.'); + const expected=line.includes('--param attentionAt=6')?1:0; + const result=spawnSync(process.execPath,[join(root,'dist/lib/cli/main.js'),...args],{cwd:home,encoding:'utf8',windowsHide:true,env:{...process.env,NO_COLOR:'1',FORCE_COLOR:'0'}}); + assert.ifError(result.error);assert.equal(result.status,expected,`${page.source}\n${line}\n${result.stderr}`);executed++; + } + } + assert.ok(executed>0,'No documented invocations were exercised.'); +}); + +test('canonical valid artifacts and relational-invalid counterparts keep separate outcomes',()=>{ + for(const name of ['exact','bounded','incomplete','unmeasurable']) { + const input=JSON.parse(read(`docs/examples/reports/${name}.json`)); + assert.equal(validateSchema('report',input).ok,true);assert.equal(readReport(input).ok,true); + } + for(const name of ['query-bounded','query-incomplete','query-paths']) assert.equal(validateSchema('query',JSON.parse(read(`docs/examples/reports/${name}.json`))).ok,true); + const reversed=JSON.parse(read('docs/examples/reports/bounded.json')); + reversed.totals.lines.changed={status:'bounded',lower:70,upper:60}; + assert.equal(validateSchema('report',reversed).ok,true);assert.equal(readReport(reversed).ok,false); + const missing=parse(read('docs/examples/policies/story/rules.yml'));missing.metrics.sourceReview.scope='undeclared'; + assert.equal(validateSchema('policy',missing).ok,true);assert.equal(compilePolicy(missing).ok,false); + const badPlan=JSON.parse(read('docs/examples/reports/plan.json')); + assert.equal(readPlan(badPlan).ok,true);badPlan.target.pullRequest++; + assert.equal(validateSchema('plan',badPlan).ok,true);const rejected=readPlan(badPlan);assert.equal(rejected.ok,false);assert.equal(rejected.diagnostics[0].code,'E_PLAN_TARGET'); + const bounded=unwrap(readReport(JSON.parse(read('docs/examples/reports/bounded.json')))); + const hold=unwrap(compilePolicy({version:1,presets:[],rules:{signal:{when:'totals.lines.changed >= 65',onUnknown:'hold',effects:{labels:{add:['review/signal'],removeWhenFalse:true}}}}})); + const plan=unwrap(createPlan(unwrap(evaluatePolicy(hold,bounded)),{repository:'example/repository',pullRequest:42})); + assert.equal(plan.held.length,1);assert.deepEqual(plan.operations,[],'Unknown does not request false-removal.'); + assert.deepEqual(sourcePosition('😀\r\nx',4),{line:2,column:1}); +}); + +test('schema inventories preserve branch-local requiredness, references and boolean schemas',()=>{ + const rows=schemaFields({type:'object',properties:{choice:{oneOf:[{type:'object',required:['n'],properties:{n:{type:['integer','null'],minimum:0}}},{type:'object',properties:{n:{$ref:'#/$defs/Number'}}}]},extra:false},$defs:{Number:{type:'number'}}}); + const first=rows.find(row=>row[0]==='`$.choice.oneOf[0].n`'), second=rows.find(row=>row[0]==='`$.choice.oneOf[1].n`'); + assert.match(first[1],/integer.*null/u);assert.match(first[2],/^Required/u);assert.match(first[5],/minimum/u); + assert.equal(second[1],'`reference`');assert.equal(second[2],'Not required here');assert.match(second[5],/#\/\$defs\/Number/u); + assert.ok(rows.some(row=>row[0]==='`$.extra`'&&row[1]==='`false`')); + assert.ok(rows.some(row=>row[4]==='`#/$defs/Number`')); +}); + +test('committed inventories are deterministic, detect byte drift and never rewrite authored framing',()=>{ + const receipts=updateInventories({root});assert.ok(receipts.length>0); + for(const receipt of receipts){assert.ok(receipt.inputs.length>0);assert.match(receipt.sha256,/^[a-f0-9]{64}$/u);} + const source='docs/manual/policy/README.md',raw=read(source); + assert.equal(committedIslands(raw,{root,source}),raw); + const drift=raw.replace('\n','\nBROKEN\n'); + assert.throws(()=>committedIslands(drift,{root,source}),/generated presets drift/u); + assert.equal(committedIslands(drift,{root,source,write:true}),raw); + assert.throws(()=>committedIslands(raw.replace('',''),{root,source}),/edit boundary/u); + const framing='An independently edited introduction.\n\n'+raw; + assert.equal(committedIslands(framing,{root,source}),framing); + assert.ok(!projectIslands(raw,{root,source}).includes('\n`,`\nBROKEN\n`),{root,source}),/drift/u); + } +}); + +test('dated release notes remain reachable but never become default current search results',()=>{ + const entry=legacyEntries.find(e=>e.source==='docs/releases/v1.0.0.md'); + assert.ok(entry); + const route=entry.route??`/docs/${entry.slug}/`; + assert.equal(indexable(route,'
Historic release
'),false); + assert.equal(indexable('/help/releases/','
Choose a release
'),true); + const targets=sourceTargets({ref:'1'.repeat(40),state,exists:p=>existsSync(join(root,p))}); + assert.ok(resolveSource('/source/?f='+encodeURIComponent(entry.source),targets)); +}); diff --git a/apps/playground/README.md b/apps/playground/README.md index bbb904a8..2f7fb1e3 100644 --- a/apps/playground/README.md +++ b/apps/playground/README.md @@ -112,3 +112,13 @@ repair; do not delete the Worker or blindly upload another version. ## Licence Application code and assets under `apps/playground/` are licensed under **GNU AGPL-3.0-only**. They consume the reusable diffdevil engine under its MIT terms. Documentation and brand rights remain governed by the repository licence map rather than inheriting the application licence by proximity. + +## Public-service operating boundary + +The configurable visitor journey is maintained in [Playground](../../docs/manual/use/playground.md). This component owns acquisition and operation, not a second visitor manual. A source build, local Worker, deployment and qualified live visitor journey remain separate observations. + +Production admission must establish visitor and service-wide compute/upstream budgets, bounded requests and responses, understandable retry behavior, credential isolation and the actual configured website/API connection. Anonymous GitHub reads share the upstream allowance of the service's originating IP; compute capacity does not remove that constraint. No authentication, persistent visitor history or permanent CAPTCHA is implied by the open experience. + +Do not introduce shared caching of contextual public PR data without explicit bounded expiry and public-visibility revalidation. Repository visibility can change after capture. Separate immutable numeric analysis from contextual data, and keep source text and PR prose out of permanent shared history. These are operator acceptance requirements, not a claim that this adapter already supplies a production cache, traffic gateway or every service-level control. The [data contract](../../docs/PRIVACY-AND-DATA.md#public-playground) remains authoritative. + +Qualify both a retained example and a real public PR through the configured service, including malformed/private input, throttling, source movement, policy editing and supported exports. A measurement-only deployment does not qualify the complete website interaction. Publication and provider configuration require their separate operator grant. diff --git a/apps/playground/public/index.html b/apps/playground/public/index.html index f82c8ed6..d2ffa3aa 100644 --- a/apps/playground/public/index.html +++ b/apps/playground/public/index.html @@ -76,8 +76,8 @@

Go from inspection to automation

diff --git a/apps/website/README.md b/apps/website/README.md index 8124b7e8..3df802d5 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -2,119 +2,181 @@ ## Meaning -This is the AGPL-3.0-only application source for diffdevil's public website: the homepage, the complete read-only playground, the examples catalogue, the manual rendered from the repository's own Markdown, the public browser-extension and GitHub App pages, and the legal/privacy routes. It is built with Astro static output and Starlight, with one lazy React island for the playground. It computes nothing itself: every number on every surface comes from the shared engine in `dist/lib`, at build time on static pages and in the browser inside the playground. The design authority is the repository's [design reference](../../design/README.md); the site applies Surface Grammar v1 and does not re-derive it. +This application owns the product front door, the browser-based Playground, real-PR +Examples, public extension/App explanations, canonical FAQ, legal routes and agent +setup/download handoffs. It uses the same engine, policies, reports and presenters +as the open CLI/library/Actions. It is not the authenticated dashboard or a second +manual: `apps/manual/` renders the selected `docs/manual/` chapters on their own host. +A source build, fixture scene or local browser check does not establish deployment, +Store availability, an ordinary installed extension journey or a public managed App. -The website is built and qualified locally. Nothing here deploys, previews remotely, routes, or exposes it; publication is a separate decision with its own Work. - -## Build, run, qualify +## Build and use locally From the repository root: ```sh npm ci --ignore-scripts --no-audit --no-fund -npm run website:build # engine build → docs collection → astro build → artifacts/website/dist -npm run website:preview # serve the built output locally -npm run website:dev # engine build → docs collection → astro dev server -npm run website:check # astro type check -npm run test:website # data-level tests (also discovered by npm test) -npm run qa:website # headless Chromium qualification of the built output +npm run website:build +npm run qa:website +npm --prefix apps/manual run qa +node apps/website/qa/faq.mjs ``` -`npm run verify` includes `check:website`, so the site must build for ordinary verification to pass. The browser qualification (`qa:website`) needs Playwright's Chromium in the user cache (`npx playwright install chromium`); it serves the build, runs the real playground application server against the in-memory fake GitHub fixture, and writes results and screenshots to `artifacts/website/qa/`. - -Configuration is environment at build time, never a decided domain: - -| Variable | Meaning | Default | -| --- | --- | --- | -| `DIFFDEVIL_SITE_ORIGIN` | Public origin used for canonical URLs, the Open Graph image and every absolute URL copied to an agent | `https://diffdevil.invalid` (placeholder; canonical links are omitted) | -| `PUBLIC_PLAYGROUND_API` | Origin of the playground API Worker the playground calls for live public pull requests | `http://127.0.0.1:4173` (the local `npm run playground` server) | -| `PUBLIC_APP_INSTALL_URL` | Canonical GitHub App installation route. Every "Install the App" / "Install on GitHub" links here directly | unset | -| `PUBLIC_CHROME_WEB_STORE_URL` | Canonical Chrome Web Store listing of diffdevil for GitHub. Every "Add to Chrome" links here directly | unset | -| `PUBLIC_DASHBOARD_URL` | The authenticated application. When set, **Dashboard** takes the header's one trailing slot from the npm copy command | unset | -| `PUBLIC_DISCORD_URL`, `PUBLIC_BLUESKY_URL` | The shared Wolfsblvt Works Discord server and Bluesky account (Community panel and footer) | unset | -| `PUBLIC_SPONSOR_URL` | Funding destination of the `#support` Sponsor action | unset | - -The last five rows are live destinations, not design decisions. The site is built as though they exist: while one is unset its control renders complete but without an `href` (`aria-disabled`), through `ActionLink.astro`, so nothing points at an invented URL and nothing says "coming soon". Set the variable and the same control is live. "Action wording goes to the action, product wording goes to the product page": no install action is ever routed through `/extension/` or `/app/`. - -## One shell, one page grid - -Every route, the manual included, renders the same header (`src/components/Header.astro`) on the same grid (`.wrap` in `src/styles/global.css`: `--content-max` wide, `--page-x` gutters, centred). The lockup therefore sits at one coordinate everywhere. A page names its top-level section with a plain label beside the lockup (`Docs`, `Examples`, `GitHub App`, `Extension`); that label sits outside the home link and is never a link. Sections put their blocks on that grid; none of them chooses its own width. - -The header applies the Wolfsblvt Works header grammar: **identity → search → navigation → one divider → utilities → one trailing action**. - -```text -[diffdevil Examples] [Search Ctrl K] Docs Playground Examples Install⌄ │ [GitHub] [Community⌄] [Theme] [npm copy | Dashboard] -``` - -- **Geometry:** 62px shell; 190 × 30 search launcher (a button, not a field) that compacts to 156 and then to a 30 × 30 icon; 13px navigation with 18px gaps; one 1 × 18 divider; 28 × 28 direct GitHub source icon; 42 × 28 Community trigger (`social-links` = Lucide `waypoints` + chevron); 64 × 28 theme control (narrower than the shared 72px default, by owner direction: two 24px sides and a small 12px middle that holds only the dot); one 30px trailing action. The current item takes one emphasis step (stronger, semibold text) and every label reserves its semibold width, so the cluster never shifts between routes. The npm command confirms a copy in place at exactly its resting width. -- **Install⌄, Community⌄, Menu⌄** are disclosures of ordinary links (`button` + `aria-expanded`/`aria-controls`), not ARIA menus: click, Enter or Space toggles; Tab walks the rows; Escape closes and refocuses the trigger; an outside click closes without stealing focus; one panel is open at a time; a panel shifts to stay inside the viewport (`lib/client.ts`). Install is a menu of the site's own pages and nothing else: browser extension first, then the GitHub App, then the CLI and Actions, each one whole-row link with a title and a short description. Direct install actions (`Add to Chrome`, `Install on GitHub`) are deliberately not in it; they live on those pages and on the homepage, as plain buttons without a link glyph. -- **Responsive, by content budget:** search compacts first; then the *whole primary navigation* collapses into `Menu⌄` in the navigation's own place, with the same destinations inside. GitHub, Community and Theme stay visible at every width and never move into the menu; the npm convenience command leaves with the navigation (Install, the hero and the manual keep the route). At the narrowest widths the redundant section label yields before any utility does. -- **Destinations** come from one product-owned list, `src/data/destinations.ts`, rendered by the Community panel (Discord, Bluesky, Wolfsblvt Works; GitHub is already the direct icon) and by the footer (icons only, never collapsed, complete: Works, Discord, Bluesky, GitHub). - -### Theme control - -`ThemeControl.astro` is one native button over three preferences and four traversal positions: **Dark → Auto → Light → Auto → Dark → …** The whole control is one target; there are no per-side hit zones and no direct Light → Dark wrap. One capsule previews the real appearance (`#111318` when dark, `#F5F6F8` when light) and, in Automatic, stretches to take the centre dot inside it. ArrowRight/ArrowLeft step forwards/backwards while it is focused; its tooltip and `aria-description` state the current preference and the next click. - -`lib/theme-script.mjs` is the single before-paint rule, shared verbatim by `Base.astro` and the manual's Starlight head: `diffdevil.theme` holds `dark | auto | light`, `diffdevil.theme.next` the manual side the bounce is heading for, and `` is always the resolved appearance. With nothing valid stored the theme is **explicit Dark**. Migration: a legacy `system` value becomes `auto`; an Auto without a stored bounce side gets the opposite of its resolved appearance, once; earlier builds removed the key for Auto, which cannot be told from "never chosen", so an absent key takes the dark-first default. Auto follows the system live; explicit choices ignore it. The preference is origin-local storage; carrying it across product hosts belongs to the documentation-host work, not to this control. - -The page never scrolls sideways. Grid tracks are `minmax(0, …)`, code and prompts wrap, and only a deliberately bounded inner block (the playground's files table on a narrow screen, a long report document, a long presenter pane) may scroll. `qa/run.mjs` measures page-level overflow on every route at 1280, 1024, 768, 640 (the layout of a 1280 window at 200 % zoom) and 390. - -Section anchors: the literal `#id` eyebrow carries the `id`, is the scroll target under the sticky header, and is a link to itself; the magenta `#` after the title is the same link for pointer users, and hovering or focusing either lights both (`SectionHead.astro`). Cross-links into the manual always close a section (`SectionLinks.astro`); the hero is the one exception. - -The manual keeps that shell. `src/components/docs/Header.astro` renders the site header inside Starlight's fixed header; `src/styles/starlight.css` puts Starlight's frame on the page grid: the navigation rail starts under the lockup, everything left of its divider (header included) is the raised ground, the divider runs through the header, and the rail scrolls on its own beneath the fixed header. Article typography in that file is provisional until the docs-page design lands. - -## Search - -`src/components/Search.astro` and `src/lib/search.ts` are the one search on every route. The build's Pagefind index covers the whole output: site pages opt in with `data-pagefind-body` on `
` (from `Base.astro`), manual pages through Starlight. Each hit is marked **Docs** or **Site** from its URL. Legal routes (`/privacy/`, `/impressum/`) pass `noindex` to `Base.astro`: they stay public and fetchable, carry `noindex, nosnippet`, and are left out of the index. The index exists only in built output; `astro dev` shows an honest "not available in this preview" instead. - -## Open design seams, and the slots that wait for them - -Two visual decisions are still with the owner. The site builds their structure and interfaces and does not invent their look: - -- **Icons.** Components never embed a drawing; they ask `Icon.astro` for a meaning (`social-links`, `discord`, `changed`, …). `src/data/icons.ts` resolves it at build time, locally and with no runtime icon request, along the three routes of the Works icon standard: UI glyphs from Lucide (`@iconify-json/lucide`), brand marks from Simple Icons (`@iconify-json/simple-icons`), and the ratified Works-authored diffdevil family (`diffdevil/brand`, `changed`, `raw-churn`, `bands`) vendored from `Wolfsblvt/wolfsblvt-icons@8596b6bc`. `@wolfsblvt/icons` is the intended consumer route and is not published yet; adopting it replaces that one file and `Icon.astro`, no caller. Bluesky and Chrome are rendered from Simple Icons like GitHub and Discord; their curated admission metadata in the shared catalogue is an open join there. The maker's website uses Lucide `globe` until the reserved `wolfsblvt/works` glyph exists. -- **Support / sponsor visual.** (still an open owner design seam) `home/Support.astro` fixes the semantics, layout and responsive behaviour with ordinary tokens and ordinary buttons. The final design plugs into `--support-ground` (the banner ground), `--support-art` on `[data-support-art]` (the right-hand art; empty until set), and the `sponsor` / `star` icon slots. - -`/impressum/` is the site's own legal-notice route and the footer links to it. Its notice text is owed from the Wolfsblvt Works legal-page standard; until this repository can consume that standard the page names the operator and points at the Company legal notice rather than improvising legal copy. - -## The browser extension on the site - -diffdevil for GitHub is one of the three operating routes (run it yourself · bring Changed into GitHub · let it run for you) and the easiest one to adopt, so it is the hero's primary action, route card 02, the `#extension` section directly after `#measure`, the first Install entry and its own page, `/extension/`. The Playground is the trial surface, not a fourth route: it has no homepage card and is reached from the header, the hero and `#measure`. - -`components/extension/Projection.astro` and `Scene.astro` render the extension's projection as semantic HTML and CSS in GitHub's own visual language (no screenshot, iframe, live fetch or script; the site's magenta stays out of it; GitHub's raw diffstat stays present, de-emphasised). Every figure comes from `extensionSpecimen()` in `lib/specimens.ts`: `docs/examples/reports/exact.json` under `full.yml`, the same 178 / +45 −18 ~115 / raw +160 −133 / size/M that `#measure` teaches. A file's virtual band is the engine's own answer for that file under the same policy. The pull request's title and branch are scene dressing. - -`/extension/` is the trust and detail surface. Its statements about what the extension reads, stores and never does follow the extension's own privacy notice, manifest and listing copy (`storage`; content script on github.com; host access to api.github.com; Incognito not allowed; local analysis; raw patches not persisted; native label handoff only); when that source changes, the page follows it. The two settings images under `src/assets/extension/` are the extension's real rendered settings UI from its store kit, cropped. The extension's manual pages are optional entries in `docs-manifest.mjs`; links to them turn live when that source is in the repository. - -## Terminal specimens are executed, not typed - -Commands shown on narrative surfaces (`#query`, the `#agents` comparison) are run at build time by the real CLI through `src/lib/cli-specimen.ts` against `docs/examples/reports/exact.json` under `docs/examples/policies/full.yml`, and the page prints that stdout and exit code. A command the CLI refuses fails the build. The `#policy` YAML and plan panels are projections of the same real policy and of the plan the engine creates for that report; only the third panel (what an explicit `apply` reports) is copy-deck illustration, because a static page cannot apply anything. Presenter output is never re-rendered by the website: when the shared human and agent presenters change, these surfaces change with them. +`website:build` builds the engine, derives product assets, projects the FAQ and exact +source provenance, builds this site, then builds the manual and one joined Pagefind +index. The two outputs are `artifacts/website/dist/` and `artifacts/manual/dist/`. +The [manual toolchain](../manual/README.md#build-and-inspect) needs its selected +source dependency and first online preparation; an offline run must have that exact +prepared dependency graph. It does not silently replace the shared package. + +`npm run website:dev` serves the product site locally. `website:preview` previews +already-built files on the local machine; neither command publishes a remote preview. +`qa:website` operates the built product and a loopback Playground API with a fake +GitHub boundary. The manual and FAQ suites exercise their actual emitted handlers, +canonical origins, search, accessibility and fragment behavior. Results describe +exact candidates and exercised boundaries, not live services. See +[Development](../../docs/DEVELOPMENT.md) for the complete command contract. + +## Sources and hosts + +The product site is `https://diffdevil.dev/`; `www.diffdevil.dev` is the matching-path +308 compatibility host. The manual is `https://docs.diffdevil.dev/`, whose root is +**What is diffdevil**. The FAQ is only `https://diffdevil.dev/faq/` and has no manual +sidebar. `https://app.diffdevil.dev/` remains the selected managed application Home, +not a static-site route or evidence that authenticated administration is usable. + +`apps/manual/manifest.mjs` owns explicit chapter routes. `docs-manifest.mjs` here is +only the finite legacy apex inventory: selected old article URLs redirect to their +manual successors; seven repository-only projections redirect to exact GitHub source. +Its old sidebar groups are compatibility inventory, not current navigation. The +ignored apex chapter collection and its old content-loader are retired. No broad +`docs/**` publication or Markdown forwarding stubs replace them. + +One Pagefind build indexes the five product reading routes, all forty-one current +manual chapters and each of the twenty FAQ questions, then mirrors the same index +into both hosts. There is no whole-FAQ hit, legacy `/docs/` hit or technical/history +spill. Legal, resolver, error and qualification pages stay outside current search. +The real Starlight manual owns its English navigation label; this product site does +not retain a spare Starlight/i18n collection. + +The shared header, footer and before-paint theme code are used by both applications. +Theme handoff carries only the allow-listed presentation preference and removes its +transport parameter. It creates no account state, shared authentication or sync +service. Source links select the build's exact commit and Edit links select the +contribution branch. Non-manual technical links use the finite `/source/?f=...` +resolver; arbitrary paths, URLs, unknown IDs and duplicate selector parameters refuse. + +## Product routes and shared examples + +The homepage presents the accepted three operating choices: run it yourself, bring +Changed into GitHub, or let the GitHub App run for a repository. The open package +remains first-class. The extension keeps its selected prominent position without a +fictional Store link. The Playground remains a separate trial route reached through +the header, hero and measurement explanation, not a fourth operation card. + +`/examples/` and `/playground/` consume `docs/examples/catalogue/`: seven real PRs, +sixteen variants and explicit frozen source editions. Links carry both `example` +and `variant`; live refresh is a separate edition. Controlled engine fixtures remain +teaching/test inputs and never become public gallery provenance. Catalogue guides +are rendered in the selected Playground result rather than copied into essays. + +Narrative terminal/agent specimens execute the real CLI and shared presenters through +`src/lib/cli-specimen.ts` and `src/lib/specimens.ts`. The small introductory replacement +and the separate 178-Changed report keep their own semantics. A failed specimen fails +the build. Policy and desired-plan projections do not imply provider application; +illustrative effect results remain explicitly distinct from live-use proof. + +## Extension and managed-App standing + +`/extension/` explains complete adoption, measurements, policy provenance, inspection, +Settings and data boundaries. Its top **In development** note remains until the full +selected public capability is usable. The semantic HTML scenes and committed Settings +captures are source/design illustrations, not proof of a signed-in GitHub journey or +final Store imagery. The accepted functional correction and the final in-place design +have separate admission boundaries. Do not harden report placement, interaction or +Store imagery from these fixtures. + +`/app/` explains why managed operation may help, its relationship to open tooling, +effective policy, independent installation/execution/history states, recovery and +self-hosting. Its top note distinguishes implemented/deployed backend components from +the complete public service. Dashboard wording, controls, experienced routes and the +first administrator journey remain co-design. No fixture supplies those decisions. +Selected history tiers do not invent published prices, quotas, billing or commitments. + +Store, App installation, dashboard, community and Sponsor actions consume explicitly +configured destinations through `ActionLink`; unconfigured actions remain visibly +unavailable. Host selection alone does not configure an install destination. +Support/Sponsor composition remains its separate co-design/source work. + +## Agent setup and downloads + +The complete `skills/diffdevil/` tree is the canonical install artifact. The raw +`/skill/SKILL.md` route is a reading projection of its core, not a complete installation. +The independent Skill version comes from source metadata. The homepage handoff uses +`/setup/skill.md`, so references, notices, version/digest verification and persistent +client discovery remain in the canonical installer instructions. + +All five `docs/setup/{skill,cli,actions,app,everything}.md` payloads are served through +deterministic binding and unresolved-token refusal. The copied intent links to the +corresponding raw instructions; human explanations remain in the manual. Executable +access is established separately from Skill reading or persistent installation. + +Downloads consume the shared release metadata and manifest contract, not another list +of versions or guessed archive URLs. Stable Skill/runtime assets that are not published +remain unavailable even when a local build can produce them. Build/package source, +release attachments and a successful installation are separate observations. ## Layout -| Path | Owns | +| Path | Responsibility | | --- | --- | -| `astro.config.mjs` | Astro + Starlight configuration, engine aliases, the browser shims for the engine's two Node imports | -| `src/pages/` | `/`, `/playground/`, `/examples/`, `/extension/`, `/app/`, `/privacy/`, `/impressum/`, `404`, the per-example JSON documents, and the raw `/skill/*` and `/setup/*.md` routes | -| `src/components/` | The shared shell: `Header`, `InstallEntries`, `DestinationRows`, `Footer`, `Search`, `ThemeControl`, `ActionLink` (links to configured live destinations), `Icon`, `SectionHead`, `SectionLinks`, `CodeBlock`, `AppPanel` (the one branded App panel used by both the homepage and `/app/`), and `extension/` (the shared projection and pull-request scene) | -| `src/components/home/` | Homepage sections; numbers computed at build through `src/lib/engine-node.ts`, `src/lib/specimens.ts` and `src/lib/cli-specimen.ts` | -| `src/islands/playground/` | The React island: URL state, browser engine wrapper, rail, controls, CodeMirror policy editor, result column with four views, the small JSON/line tokenizer for read-only data (`highlight.tsx`), export dialog | -| `src/data/` | The keyed copy deck (`copy.ts`), site facts and configured destinations (`site.ts`), the social destination list (`destinations.ts`) and the icon registry (`icons.ts`) | -| `src/styles/` | Canonical tokens import with the documented light-cascade correction, self-hosted fonts, surface grammar, playground and Starlight mappings | -| `src/lib/` | Build-time engine access and executed CLI specimens, example catalogue reader, agent-source reader, Shiki theme, the pre-paint theme script, search, shared client behaviour | -| `src/shims/` | `node:crypto` (SHA-256) and `node:util` stand-ins so the engine runs in the browser unchanged | -| `../docs/examples/catalogue/` | The shared real-PR lesson catalogue: source identities, policies, snapshots and guide. `/examples/` and the Playground read this one source; fixtures remain engine-test inputs only | -| `docs-manifest.mjs` | The manual's reader-facing selection; `tools/website-docs.mjs` generates the ignored Starlight collection from it | -| `public/` | Static files. The derived favicons, touch icon, web-manifest and GitHub App logos, Open Graph image and manifest are **generated, ignored output** of `tools/website-assets.mjs` from the canonical identity SVGs; the build regenerates them and Astro copies them into the site | -| `qa/run.mjs` | Browser qualification | -| `website.test.mjs` | Data-level tests | - -## Derived assets - -The identity SVGs under `design/assets/identity/` are the only committed source. `npm run website:assets` renders every icon and raster the site needs into `apps/website/public/` as ignored files; `website:build`, `website:dev` and `check:website` run it first. The generator frames one master mark for several consumers (`FRAMES` in `tools/website-assets.mjs`): the web-manifest icon and the GitHub App badge are the same mark with different outer pads, and each frame declares the canvas occupancy its visible mark must land in. The generator measures the rendered bounds and refuses to emit a frame outside its range (`--report` prints the measurements), so adjusting a badge is one number and one exported candidate to judge, never a hand crop. No raster is committed; the GitHub App registration is the one consumer that cannot receive build output, and it takes the generated `github-app-logo-512.png` by explicit upload. - -## Sources the site serves but does not author - -The persistent Agent Skill (canonical home `skills/diffdevil/SKILL.md`, served at `/skill/SKILL.md` with its front-matter `metadata.version` at `/skill/version`) and the one-off setup instructions (`docs/setup/{cli,actions,app,everything}.md`, served raw at `/setup/*.md` and rendered in the manual) are repository-owned product content authored separately. The build reports which of them are present; an absent source emits no route and no placeholder. The homepage Agents chapter, its copy prompts and the version chip read those sources; the version is never hard-coded. - -## Licence - -Application code and assets under `apps/website/` are **GNU AGPL-3.0-only**. They consume the reusable diffdevil engine under its MIT terms. The identity assets under `design/assets/identity/` and the derived rasters in `public/` are reserved brand material, not covered by the software licences. IBM Plex is used under the SIL Open Font License. +| `astro.config.mjs` | Static Astro product pages, React Playground and shared-engine aliases. | +| `src/pages/` | Product, Playground, Examples, extension, App, FAQ, resolver, legal and raw setup/download routes. | +| `src/components/` | Shared header/footer/search/theme, configured actions and source-backed product illustrations. | +| `src/components/home/` | Homepage sections and executable/shared-presenter specimens. | +| `src/islands/playground/` | Input, policy editing, comparison, result views and portable exports. | +| `src/data/` | Keyed copy, selected origins/routes, configured destinations and icons. | +| `src/lib/` | Engine access, catalogue and agent-source consumers, rendering, search and presentation helpers. | +| `src/styles/` | Accepted tokens, fonts and product/reading grammar, including mappings used by the manual. | +| `src/shims/` | Browser implementations for the engine's selected Node imports. | +| `docs-manifest.mjs` | Finite legacy route/source inventory, not an active manual. | +| `src/generated/` | Ignored FAQ and exact source-provenance projections; never separately authored. | +| `public/` | Static files plus ignored derived brand assets. | +| `qa/` | Product and FAQ browser qualification; the manual suite owns joined reading journeys. | + +## Assets and legal surfaces + +The canonical identity SVGs remain under `design/assets/identity/`; the asset builder +produces ignored icons/rasters with its checked framing. Reuse the accepted icon family +and existing shared imports instead of drawing feature-local substitutes. Rendered +Store/source imagery retains its provenance and does not become live-use evidence. + +Legal pages are public but `noindex, nosnippet` and excluded from site search. Product +data/security links lead to the manual and its maintained authorities; the website +privacy page owns website processing only. The operator notice points to its real +Company source rather than inventing legal identity or terms here. + +## License + +Application software is AGPL-3.0-only, the reusable engine is MIT, documentation and +reserved visual rights follow the [component map](../../LICENSES/README.md), and +third-party fonts retain their own notices. + +## Setup and download build inputs + +`apps/website/setup-render.mjs` binds the five canonical `docs/setup/` payloads. +`PUBLIC_ORIGIN` uses the configured public site origin. `APP_INSTALL_URL` and +`APP_DASHBOARD_URL` use `PUBLIC_APP_INSTALL_URL` and `PUBLIC_DASHBOARD_URL`. Missing +App destinations produce an explicit unavailable response at `/setup/app.md`; +unknown tokens and invalid URLs fail instead of leaking placeholders. GitHub +Actions expressions remain unchanged. `/skill/SKILL.md` remains a byte-identical +reading projection, not a complete installation. + +`/downloads/skill/`, `/downloads/standalone/` and `/downloads/bundled/` consume the +existing `tools/build-release.mjs` manifest keys. A later publication operation +may supply `DIFFDEVIL_PUBLISHED_RELEASE_MANIFEST` as the local path of that exact +manifest after publication and asset readback. It must not bind a merely built +unpublished candidate. No binding means a visible unavailable state, not a guessed +archive URL. Versions, sizes, digests and destinations come from the manifest; +the site performs no publication or runtime discovery. These utility pages stay +out of joined current search. diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index 2e11579f..3216e389 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -1,70 +1,27 @@ // SPDX-License-Identifier: AGPL-3.0-only import { defineConfig } from 'astro/config'; -import starlight from '@astrojs/starlight'; import react from '@astrojs/react'; -import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { diffdevilSyntax } from './src/lib/shiki-theme.ts'; -import { themeScript } from './src/lib/theme-script.mjs'; import searchIndex from './search-index.mjs'; +import { origins } from './public-origins.mjs'; +import { engineAliases } from './engine-aliases.mjs'; const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); -const engine = path => fileURLToPath(new URL(`../../dist/lib/${path}`, import.meta.url)); -const sidebarFile = new URL('./src/content/sidebar.json', import.meta.url); -if (!existsSync(sidebarFile)) throw new Error('Run `node tools/website-docs.mjs` before building the website; the docs collection is generated from the manifest.'); -const sidebar = JSON.parse(readFileSync(sidebarFile, 'utf8')); export default defineConfig({ - site: process.env.DIFFDEVIL_SITE_ORIGIN ?? 'https://diffdevil.invalid', + site: process.env.DIFFDEVIL_SITE_ORIGIN ?? origins.site, output: 'static', trailingSlash: 'always', outDir: fileURLToPath(new URL('../../artifacts/website/dist', import.meta.url)), integrations: [ - starlight({ - title: 'diffdevil', - description: 'The diffdevil manual: CLI, GitHub Actions, policies and the detail language, TypeScript API, playground and GitHub App.', - sidebar, - pagefind: false, - customCss: ['./src/styles/fonts.css', './src/styles/tokens.css', './src/styles/global.css', './src/styles/starlight.css'], - components: { - Header: './src/components/docs/Header.astro', - ThemeProvider: './src/components/docs/ThemeProvider.astro', - ThemeSelect: './src/components/docs/ThemeSelect.astro', - Footer: './src/components/docs/Footer.astro', - SiteTitle: './src/components/docs/SiteTitle.astro', - }, - editLink: { baseUrl: 'https://github.com/Wolfsblvt/diffdevil/edit/main/' }, - social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/Wolfsblvt/diffdevil' }], - expressiveCode: { themes: [diffdevilSyntax], useStarlightDarkModeSwitch: false, useStarlightUiThemeColors: false, styleOverrides: { borderRadius: '10px', borderColor: '#2c303a', codeFontFamily: 'var(--font-mono)', codeFontSize: '12.5px', codeLineHeight: '1.6', frames: { editorActiveTabBackground: '#0c0f17', editorTabBarBackground: '#0c0f17', terminalBackground: '#0c0f17', terminalTitlebarBackground: '#0c0f17', editorBackground: '#0c0f17', shadowColor: 'transparent' } } }, - head: [ - { tag: 'script', content: themeScript }, - { tag: 'link', attrs: { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } }, - { tag: 'link', attrs: { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' } }, - { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } }, - { tag: 'link', attrs: { rel: 'manifest', href: '/manifest.webmanifest' } }, - { tag: 'meta', attrs: { name: 'theme-color', content: '#151922' } }, - ], - pagination: true, - lastUpdated: false, - credits: false, - favicon: '/favicon.svg', - disable404Route: true, - }), react(), searchIndex(), ], markdown: { shikiConfig: { theme: diffdevilSyntax } }, vite: { resolve: { - alias: [ - { find: /^node:crypto$/u, replacement: fileURLToPath(new URL('./src/shims/node-crypto.mjs', import.meta.url)) }, - { find: /^node:util$/u, replacement: fileURLToPath(new URL('./src/shims/node-util.mjs', import.meta.url)) }, - { find: /^@wolfsblvt\/diffdevil\/core$/u, replacement: engine('core.js') }, - { find: /^@wolfsblvt\/diffdevil\/policy$/u, replacement: engine('policy/index.js') }, - { find: /^@wolfsblvt\/diffdevil\/language$/u, replacement: engine('language/index.js') }, - { find: /^@wolfsblvt\/diffdevil\/format$/u, replacement: engine('format.js') }, - { find: /^@wolfsblvt\/diffdevil\/errors$/u, replacement: engine('errors.js') }, - ], + alias: engineAliases, }, server: { fs: { allow: [repositoryRoot] } }, build: { commonjsOptions: { include: [/node_modules/, /dist[\\/]lib[\\/]validation/] }, chunkSizeWarningLimit: 900 }, diff --git a/apps/website/docs-manifest.mjs b/apps/website/docs-manifest.mjs index 92f0f311..8dffae2c 100644 --- a/apps/website/docs-manifest.mjs +++ b/apps/website/docs-manifest.mjs @@ -1,13 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-only -/** - * The manual's reader-facing selection. Every entry names one maintained repository - * Markdown source and the site slug it renders at. The site publishes this list, not - * `docs/**`: internal records (Vision, Direction, Decisions, qualification, dated - * references, publication procedure) stay in the repository and are linked, not - * rendered. Sources listed with `optional: true` are rendered when present. - * - * `tested: true` marks pages whose command/policy/workflow specimens are executed by - * `src/diffdevil/tests/examples.test.mjs`; the docs footer shows "Example tested ✓" there. +/** Historical apex manual routes and their selected repository sources. + * This finite inventory preserves old URLs and source IDs during the final cutover. + * It is not an active article selection, sidebar, or second public manual. + * apps/manual/manifest.mjs and authoring-state.json select manual successors; + * repositoryOnly entries keep their distinct technical job on GitHub. */ import { FAQ_ROUTE, FAQ_SOURCE } from './faq-content.mjs'; @@ -22,7 +18,7 @@ export const groups = [ { source: 'docs/guides/auto-label-pull-requests.md', faq: ["beyond-size-labels", "automation-overlap", "trusted-policy"], slug: 'get-started/auto-label-pull-requests', tested: true }, { source: 'docs/guides/local-automation.md', faq: ["non-javascript-projects", "no-language-required"], slug: 'get-started/local-automation', tested: true }, { source: 'docs/automation.md', slug: 'get-started/automation' }, - { source: 'docs/examples/README.md', slug: 'get-started/examples', title: 'Example map' }, + { source: 'docs/examples/README.md', slug: 'get-started/examples', title: 'Example map', repositoryOnly: true }, ] }, { label: 'Recipes', entries: [ { source: 'docs/guides/policy-recipes.md', slug: 'recipes/policy-recipes', tested: true }, @@ -33,7 +29,7 @@ export const groups = [ ] }, { label: 'Actions', entries: [ { source: 'docs/integration/github-actions.md', faq: ["automation-overlap", "trusted-policy"], slug: 'actions/github-actions', tested: true }, - { source: 'docs/integration/action-distribution.md', slug: 'actions/action-distribution' }, + { source: 'docs/integration/action-distribution.md', slug: 'actions/action-distribution', repositoryOnly: true }, { source: 'docs/integration/github-api.md', faq: ["code-data"], slug: 'actions/github-api' }, ] }, { label: 'Policies and detail', entries: [ @@ -47,7 +43,7 @@ export const groups = [ { source: 'docs/language/standard-library.md', slug: 'policies-and-detail/standard-library' }, { source: 'docs/language/diagnostics-and-limits.md', slug: 'policies-and-detail/diagnostics-and-limits' }, { source: 'docs/language/versioning-and-interchange.md', slug: 'policies-and-detail/versioning-and-interchange' }, - { source: 'docs/language/parser-architecture.md', slug: 'policies-and-detail/parser-architecture' }, + { source: 'docs/language/parser-architecture.md', slug: 'policies-and-detail/parser-architecture', repositoryOnly: true }, ] }, { label: 'Library API', entries: [ { source: 'docs/integration/typescript-api.md', slug: 'library-api', title: 'Library API' }, @@ -65,18 +61,17 @@ export const groups = [ ] }, { label: 'Playground', entries: [ { source: 'docs/integration/playground.md', faq: ["playground-or-extension", "code-data"], slug: 'playground', title: 'Playground' }, - { source: 'apps/playground/README.md', slug: 'playground/application', title: 'Playground application and local route' }, + { source: 'apps/playground/README.md', slug: 'playground/application', title: 'Playground application and local route', repositoryOnly: true }, ] }, { label: 'GitHub App', entries: [ { source: 'docs/integration/github-app.md', faq: ["why-managed", "leaving-managed-service"], slug: 'github-app/architecture', title: 'App architecture' }, { source: 'docs/PRIVACY-AND-DATA.md', faq: ["code-data", "app-history"], slug: 'github-app/privacy-and-data', title: 'Privacy and data' }, { source: 'apps/github-app/README.md', faq: ["leaving-managed-service"], slug: 'github-app/self-hosting', title: 'Self-hosting the runtime' }, ] }, - // The browser extension's manual pages are authored with the extension application. - // They render as soon as that source is in the repository; until then the group is empty. + // Historical extension projections now redirect; the component sources remain on GitHub. { label: 'Browser extension', entries: [ { source: 'apps/browser-extension/README.md', faq: ["playground-or-extension", "extension-without-repo-setup"], slug: 'browser-extension', title: 'diffdevil for GitHub', optional: true }, - { source: 'apps/browser-extension/privacy.md', faq: ["code-data"], slug: 'browser-extension/privacy', title: 'Extension privacy', optional: true }, + { source: 'apps/browser-extension/privacy.md', faq: ["code-data"], slug: 'browser-extension/privacy', title: 'Extension privacy', optional: true, repositoryOnly: true }, { source: 'docs/integration/browser-extension.md', faq: ["different-results"], slug: 'browser-extension/integration', title: 'Browser integration', optional: true }, ] }, { label: 'Help', entries: [ @@ -89,25 +84,12 @@ export const groups = [ ] }, { label: 'Releases', entries: [ { source: 'docs/releases/README.md', slug: 'releases', title: 'Releases' }, - { source: 'docs/releases/v1.0.0.md', slug: 'releases/v1-0-0', title: 'v1.0.0' }, + { source: 'docs/releases/v1.0.0.md', slug: 'releases/v1-0-0', title: 'v1.0.0', historical: true, repositoryOnly: true }, ] }, { label: 'Project', entries: [ { source: 'SECURITY.md', slug: 'security', title: 'Security' }, - { source: 'LICENSES/README.md', slug: 'licences', title: 'Licences' }, + { source: 'LICENSES/README.md', slug: 'licences', title: 'Licences', repositoryOnly: true }, ] }, ]; -/** Assets the playground carries as fixtures: a docs link to one gets a "Try it" companion. */ -export const tryIt = { - 'docs/examples/diffs/review.diff': 'replacement-once', - 'docs/examples/diffs/few-vs-many.diff': 'few-vs-many', - 'docs/examples/policies/full.yml': 'lockfile-excluded', - 'docs/examples/policies/review-signals.yml': 'custom-metric-comment', - 'docs/examples/policies/review-comment.yml': 'custom-metric-comment', - 'docs/examples/reports/exact.json': 'lockfile-excluded', - 'docs/examples/reports/bounded.json': 'bounded-band-proven', - 'docs/examples/reports/incomplete.json': 'incomplete-unknown', - 'docs/examples/reports/unmeasurable.json': 'binary-unmeasurable', -}; - export const entries = groups.flatMap(group => group.entries.filter(entry => entry.source)); diff --git a/apps/website/download-source.mjs b/apps/website/download-source.mjs new file mode 100644 index 00000000..51faadd2 --- /dev/null +++ b/apps/website/download-source.mjs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readFileSync } from 'node:fs'; + +/** Existing build-release manifest keys, not a second version or asset registry. */ +export const downloadKinds = ['skill', 'standalone', 'bundled']; + +/** Consume a publication-read-back copy of the existing release manifest. + * No network discovery, guessed archive name, source-version fallback or release + * occurs at build time. Without an explicit published input every archive is unavailable. + */ +export function publishedRelease(path = process.env.DIFFDEVIL_PUBLISHED_RELEASE_MANIFEST) { + if (!path) return undefined; + return validatePublishedRelease(JSON.parse(readFileSync(path, 'utf8'))); +} + +export function validatePublishedRelease(release) { + const semver = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; + if (release?.kind !== 'diffdevil.release-manifest' || release.schemaVersion !== '1.0' || + release.source?.repository !== 'Wolfsblvt/diffdevil' || !/^[a-f0-9]{40}$/u.test(release.source?.commit ?? '') || + !semver.test(release.productVersion ?? '') || !semver.test(release.skills?.diffdevil?.version ?? '') || + release.skills.diffdevil.sourceCommit !== release.source.commit || + release.skills.diffdevil.sourcePath !== 'skills/diffdevil' || + !/^[a-f0-9]{64}$/u.test(release.skills.diffdevil.treeSha256 ?? '')) { + throw new Error('Invalid published diffdevil release manifest identity.'); + } + for (const kind of downloadKinds) { + const asset = release.assets?.[kind]; + if (!asset || !/^[a-zA-Z0-9._-]+\.zip$/u.test(asset.name ?? '') || + !/^[a-f0-9]{64}$/u.test(asset.sha256 ?? '') || !Number.isSafeInteger(asset.size) || asset.size <= 0) { + throw new Error(`Invalid published release asset: ${kind}`); + } + const expected = `https://github.com/Wolfsblvt/diffdevil/releases/download/v${release.productVersion}/${asset.name}`; + if (asset.url !== expected) throw new Error(`Published asset URL is not its exact release destination: ${kind}`); + } + return release; +} diff --git a/apps/website/engine-aliases.mjs b/apps/website/engine-aliases.mjs new file mode 100644 index 00000000..4f077186 --- /dev/null +++ b/apps/website/engine-aliases.mjs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { fileURLToPath } from 'node:url'; + +// Both static hosts use the same compiled engine and browser shims. +const engine = path => fileURLToPath(new URL(`../../dist/lib/${path}`, import.meta.url)); +export const engineAliases = [ + { find: /^node:crypto$/u, replacement: fileURLToPath(new URL('./src/shims/node-crypto.mjs', import.meta.url)) }, + { find: /^node:util$/u, replacement: fileURLToPath(new URL('./src/shims/node-util.mjs', import.meta.url)) }, + { find: /^@wolfsblvt\/diffdevil\/core$/u, replacement: engine('core.js') }, + { find: /^@wolfsblvt\/diffdevil\/policy$/u, replacement: engine('policy/index.js') }, + { find: /^@wolfsblvt\/diffdevil\/language$/u, replacement: engine('language/index.js') }, + { find: /^@wolfsblvt\/diffdevil\/format$/u, replacement: engine('format.js') }, + { find: /^@wolfsblvt\/diffdevil\/errors$/u, replacement: engine('errors.js') }, +]; diff --git a/apps/website/public-origins.mjs b/apps/website/public-origins.mjs new file mode 100644 index 00000000..e45c8f70 --- /dev/null +++ b/apps/website/public-origins.mjs @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: AGPL-3.0-only +export const origins = Object.freeze({ site: 'https://diffdevil.dev', docs: 'https://docs.diffdevil.dev', app: 'https://app.diffdevil.dev', compatibility: 'https://www.diffdevil.dev' }); diff --git a/apps/website/qa/faq.mjs b/apps/website/qa/faq.mjs index 2dd8ffa9..b0c63904 100644 --- a/apps/website/qa/faq.mjs +++ b/apps/website/qa/faq.mjs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only /** Actual built-site FAQ, shared navigation, Pagefind and browser interaction qualification. */ import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; import { readFile, mkdir, writeFile } from 'node:fs/promises'; import { resolve, extname, sep } from 'node:path'; +import { origins, pageUrl } from '../../manual/manifest.mjs'; import { chromium, expect } from '@playwright/test'; import { faqRecords, FAQ_CANONICAL, FAQ_SOURCE } from '../faq-content.mjs'; @@ -14,19 +15,26 @@ const authored = faqRecords(await readFile(resolve(FAQ_SOURCE), 'utf8')); assert.deepEqual(records.map(({ id, title }) => ({ id, title })), authored.map(({ id, title }) => ({ id, title }))); const authoredLinkCount = authored.reduce((count, question) => count + [...question.html.matchAll(/\[[^\]]+\]\([^)]+\)/gu)].length, 0); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.wasm': 'application/wasm', '.woff2': 'font/woff2', '.png': 'image/png', '.ico': 'image/x-icon' }; -const server = createServer(async (request, response) => { - try { - let pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); - if (pathname.endsWith('/')) pathname += 'index.html'; - const file = resolve(directory, `.${pathname}`); - if (!file.startsWith(directory + sep)) throw new Error('Outside site'); - const content = await readFile(file); - response.writeHead(200, { 'Content-Type': mime[extname(file)] ?? 'application/octet-stream' }); - response.end(content); - } catch { response.writeHead(404); response.end('Not found'); } -}); -await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); -const origin = `http://127.0.0.1:${server.address().port}`; +const origin = new URL(FAQ_CANONICAL).origin; +const builtHosts = new Map([ + [origins.site, { directory, handler: (await import(pathToFileURL(resolve(directory, '_worker.js')).href)).default }], + [origins.docs, { directory: resolve('artifacts/manual/dist'), handler: (await import(pathToFileURL(resolve('artifacts/manual/dist/_worker.js')).href)).default }], +]); +/** Exercise the emitted host handlers; only their asset binding is local. */ +async function builtResponse(target) { + const url = new URL(target); + const host = builtHosts.get(url.origin); + if (!host) throw new Error(`Unexpected external request: ${url.href}`); + return host.handler.fetch(new Request(url), { ASSETS: { fetch: async request => { + try { + let pathname = decodeURIComponent(new URL(request.url).pathname); + if (pathname.endsWith('/')) pathname += 'index.html'; + const file = resolve(host.directory, `.${pathname}`); + if (!file.startsWith(host.directory + sep)) return new Response('Outside site', { status: 400 }); + return new Response(await readFile(file), { headers: { 'Content-Type': mime[extname(file)] ?? 'application/octet-stream' } }); + } catch { return new Response('Not found', { status: 404 }); } + } } }); +} const failures = [], evidence = []; let browser, page; try { @@ -36,11 +44,16 @@ try { await context.grantPermissions(['clipboard-read', 'clipboard-write']); page = await context.newPage(); page.on('pageerror', error => failures.push(error.message)); - await context.route('**/*', route => { - if (new URL(route.request().url()).origin === origin) return route.continue(); - failures.push(`Unexpected external request: ${route.request().url()}`); - return route.abort(); - }); + const serveStatic = async route => { + try { + const response = await builtResponse(route.request().url()); + return route.fulfill({ status: response.status, headers: Object.fromEntries(response.headers), body: Buffer.from(await response.arrayBuffer()) }); + } catch (error) { + failures.push(error.message); + return route.abort(); + } + }; + await context.route('**/*', serveStatic); await page.goto(`${origin}/faq/`); await page.evaluate(() => document.fonts.ready); await expect(page.locator('h1')).toHaveCount(1); @@ -111,18 +124,30 @@ try { // Resolve the real built destinations and fragments selected by the FAQ's Markdown links. const links = await page.locator('.faq-answer a').evaluateAll(anchors => anchors.map(anchor => anchor.getAttribute('href'))); for (const href of links) { - if (!href.startsWith('/')) continue; - const target = new URL(href, origin); - const response = await context.request.get(target.href); - assert.equal(response.status(), 200, href); + let target = new URL(href, origin); + if (!builtHosts.has(target.origin)) continue; + let response = await builtResponse(target); + for (let redirects = 0; response.status === 308 && redirects < 10; redirects++) { + const next = new URL(response.headers.get('Location'), target); + if (!next.hash) next.hash = target.hash; + target = next; + response = await builtResponse(target); + } + assert.equal(response.status, 200, href); if (target.hash) { const html = await response.text(); assert.ok(html.includes(`id="${decodeURIComponent(target.hash.slice(1))}"`), href); } } - await page.goto(`${origin}/docs/actions/github-actions/`); - await expect(page.locator('a[href="/faq/#trusted-policy"]').first()).toBeVisible(); - await page.locator('a[href="/faq/#trusted-policy"]').first().click(); + const legacyManualUrl = new URL(`${origin}/docs/actions/github-actions/`); + const legacyManualResponse = await builtResponse(legacyManualUrl); + assert.equal(legacyManualResponse.status, 308); + const manualDestination = new URL(legacyManualResponse.headers.get('Location'), legacyManualUrl); + assert.equal(manualDestination.href, pageUrl('github-actions')); + await page.goto(manualDestination.href); + const related = page.locator(`a[href="${FAQ_CANONICAL}#trusted-policy"]`).first(); + await expect(related).toBeVisible(); + await related.click(); await expect(page.locator('#trusted-policy')).toBeVisible(); evidence.push('Rendered deeper links and fragments resolve; a contextual manual link opens the matching FAQ answer.'); @@ -150,6 +175,7 @@ try { evidence.push('Responsive layout at 320–1440px, compact FAQ navigation, light/mobile deep link and malformed-fragment recovery.'); const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1280, height: 900 } }); + await plain.route('**/*', serveStatic); const noScript = await plain.newPage(); await noScript.goto(`${origin}/faq/`); const featured = noScript.locator('[data-faq-id="beyond-size-labels"]'); @@ -172,5 +198,4 @@ try { throw error; } finally { await browser?.close(); - await new Promise(resolve => server.close(resolve)); } diff --git a/apps/website/qa/run.mjs b/apps/website/qa/run.mjs index fec51d49..cc5d37e0 100644 --- a/apps/website/qa/run.mjs +++ b/apps/website/qa/run.mjs @@ -18,7 +18,9 @@ import { chromium } from '@playwright/test'; import { createServer } from 'node:http'; import { readFile, mkdir, writeFile } from 'node:fs/promises'; import { once } from 'node:events'; -import { join, extname } from 'node:path'; +import { join, extname, resolve, sep } from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {origins, pageUrl} from '../../manual/manifest.mjs'; import { GitHubClient } from '../../../dist/lib/index.js'; import { FakeGitHub } from '../../../src/diffdevil/tests/helpers/github.mjs'; import { createPlaygroundServer } from '../../playground/server.mjs'; @@ -31,7 +33,7 @@ const OUT = 'artifacts/website/qa'; // product's default local route. const SITE_PORT = Number(process.env.DIFFDEVIL_WEBSITE_QA_SITE_PORT ?? 4399); const API_PORT = Number(process.env.DIFFDEVIL_WEBSITE_QA_API_PORT ?? 4173); -const types = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.webp': 'image/webp', '.ico': 'image/x-icon', '.json': 'application/json', '.md': 'text/markdown', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain' }; +const types = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.webp': 'image/webp', '.ico': 'image/x-icon', '.json': 'application/json', '.md': 'text/markdown', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain', '.wasm':'application/wasm' }; await mkdir(OUT, { recursive: true }); const site = createServer(async (req, res) => { @@ -52,21 +54,40 @@ const results = []; const failures = []; function check(name, condition, detail = '') { results.push({ name, ok: !!condition, detail }); if (!condition) failures.push(`${name}${detail ? ` — ${detail}` : ''}`); } -const browser = await chromium.launch(); +const browser = await chromium.launch({executablePath:process.env.PLAYWRIGHT_EXECUTABLE_PATH}); const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, colorScheme: 'dark', permissions: ['clipboard-read', 'clipboard-write'] }); const consoleErrors = [], external = [], pageErrors = []; context.on('page', page => { page.on('console', m => { if (m.type() === 'error') consoleErrors.push(`${page.url()} ${m.text()}`); }); page.on('pageerror', e => pageErrors.push(`${page.url()} ${e.message}`)); - page.on('request', r => { const u = new URL(r.url()); if (!['127.0.0.1', 'localhost'].includes(u.hostname)) external.push(r.url()); }); + page.on('request', r => { const u = new URL(r.url()); if (!['127.0.0.1', 'localhost', new URL(origins.site).hostname, new URL(origins.docs).hostname].includes(u.hostname)) external.push(r.url()); }); +}); +// Cross-host links use the actual static output and emitted handlers. The local +// Playground API remains a separate loopback fake; no remote provider is contacted. +const builtHosts=new Map(await Promise.all([['site','website'],['docs','manual']].map(async([host,name])=>{ + const directory=resolve(`artifacts/${name}/dist`); + return [origins[host],{directory,handler:(await import(pathToFileURL(join(directory,'_worker.js')).href)).default}]; +}))); +await context.route(/^https:\/\/(?:docs\.)?diffdevil\.dev\//u,async route=>{ + const request=new Request(route.request().url()); + const host=builtHosts.get(new URL(request.url).origin); + const response=await host.handler.fetch(request,{ASSETS:{fetch:async request=>{ + let pathname;try{pathname=decodeURIComponent(new URL(request.url).pathname);}catch{return new Response('Bad path',{status:400});} + if(pathname.endsWith('/'))pathname+='index.html'; + const file=resolve(host.directory,'.'+pathname); + if(!file.startsWith(host.directory+sep))return new Response('Outside site',{status:400}); + try{return new Response(await readFile(file),{headers:{'Content-Type':types[extname(file)]??'application/octet-stream'}});} + catch{return new Response('Not found',{status:404});} + }}}); + await route.fulfill({status:response.status,headers:Object.fromEntries(response.headers),body:Buffer.from(await response.arrayBuffer())}); }); const page = await context.newPage(); try { -const go = (path, opts = {}) => page.goto(`http://127.0.0.1:${SITE_PORT}${path}`, { waitUntil: 'networkidle', ...opts }); +const go = (path, opts = {}) => page.goto(/^https?:/u.test(path)?path:`http://127.0.0.1:${SITE_PORT}${path}`, { waitUntil: 'networkidle', ...opts }); const shot = name => page.screenshot({ path: join(OUT, `${name}.png`), fullPage: true }); // ── every page: Impressum in the footer, one h1, theme script before paint ── -const pages = ['/', '/playground/', '/examples/', '/extension/', '/app/', '/privacy/', '/impressum/', '/docs/', '/docs/cli/', '/docs/get-started/auto-label-pull-requests/', '/docs/security/', '/docs/licences/', '/nothing-here/']; +const pages = ['/', '/playground/', '/examples/', '/extension/', '/app/', '/privacy/', '/impressum/', '/faq/', pageUrl('what-is-diffdevil'), pageUrl('cli'), pageUrl('label-pull-requests'), pageUrl('security-and-data'), '/source/?f=LICENSES%2FREADME.md', '/nothing-here/']; const overflowOf = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); // One header geometry and one page grid: measured on every route and compared with the homepage. const shellOf = () => page.evaluate(() => { @@ -79,7 +100,7 @@ let homeShell; for (const path of pages) { const response = await go(path); const impressum = await page.locator('footer a', { hasText: 'Impressum' }).first(); - check(`${path} has an Impressum link to the site's own route`, await impressum.count() === 1 && (await impressum.getAttribute('href')) === '/impressum/'); + check(`${path} has an Impressum link to the site's own route`, await impressum.count() === 1 && new URL(await impressum.getAttribute('href'),page.url()).pathname === '/impressum/'); check(`${path} has no Company-privacy footer link`, (await page.locator('footer a', { hasText: 'Company privacy' }).count()) === 0); check(`${path} has no page-level horizontal overflow at 1280`, (await overflowOf()) <= 0, String(await overflowOf())); const shell = await shellOf(); @@ -104,7 +125,7 @@ await shot('home'); check('header grammar: search, navigation, one divider, utilities, one trailing action', homeShell.order === 'search-trigger primary-nav header-sep utilities trailing-action', homeShell.order); const geometry = await page.evaluate(() => { const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return `${Math.round(r.width)}x${Math.round(r.height)}`; }; return { header: b('.site-header'), search: b('.search-trigger'), source: b('[data-direct-source]'), social: b('.utility-social'), theme: b('.theme-control'), action: Math.round(document.querySelector('.trailing-action').getBoundingClientRect().height), sep: b('.header-sep'), navSize: getComputedStyle(document.querySelector('.site-nav > li > a')).fontSize, navGap: getComputedStyle(document.querySelector('.site-nav')).columnGap }; }); check('header geometry follows the shared standard', JSON.stringify(geometry) === JSON.stringify({ header: '1265x62', search: '190x30', source: '28x28', social: '42x28', theme: '64x28', action: 30, sep: '1x18', navSize: '13px', navGap: '18px' }), JSON.stringify(geometry)); -check('nav has Docs, Playground, Examples and Install; there is no top-level App item', (await page.locator('.site-nav > li').allInnerTexts()).map(t => t.trim()).join('|') === 'Docs|Playground|Examples|Install'); +check('nav has Docs, Playground, Examples and Install; there is no top-level App item', (await page.locator('.site-nav > li').allInnerTexts()).map(t => t.trim()).join('|') === 'Docs|Playground|Examples|Install|FAQ'); check('hero shows 3 changed = exact from the engine', /3\s*changed/u.test(await page.locator('.specimen-cell').first().innerText())); const hero = await page.evaluate(() => { const lines = el => Math.round(el.getBoundingClientRect().height / parseFloat(getComputedStyle(el).lineHeight)); @@ -144,7 +165,7 @@ const install = page.locator('[aria-controls="panel-install"]'), communityTrigge await install.focus(); await page.keyboard.press('Enter'); check('Install opens by keyboard and exposes its expanded state', (await install.getAttribute('aria-expanded')) === 'true' && await page.locator('#panel-install').isVisible()); const installLinks = await page.locator('#panel-install a').evaluateAll(links => links.map(a => `${a.dataset.dest}=${a.getAttribute('href') ?? 'unconfigured'}`)); -check('Install is a menu of site pages only: browser extension, GitHub App, CLI and Actions; no install or store action lives in it', installLinks.join(' ') === 'install:extension=/extension/ install:app=/app/ install:open=/docs/get-started/auto-label-pull-requests/' && !/Add to Chrome|Install on GitHub/u.test(await page.locator('#panel-install').innerText()), installLinks.join(' ')); +check('Install is a menu of site pages only: browser extension, GitHub App, CLI and Actions; no install or store action lives in it', installLinks.join(' ') === `install:extension=/extension/ install:app=/app/ install:open=${pageUrl('label-pull-requests')}` && !/Add to Chrome|Install on GitHub/u.test(await page.locator('#panel-install').innerText()), installLinks.join(' ')); const rowsShape = await page.evaluate(() => [...document.querySelectorAll('#panel-install .dest-row')].map(row => { const li = row.parentElement.getBoundingClientRect(), a = row.getBoundingClientRect(); return { described: !!row.querySelector('.dest-label') && !!row.querySelector('.dest-note')?.textContent.trim(), whole: Math.round(a.width) === Math.round(li.width) && Math.round(a.height) === Math.round(li.height), gap: getComputedStyle(row).columnGap }; })); check('every Install entry has a title and a description, and the whole row is the link', rowsShape.length === 3 && rowsShape.every(r => r.described && r.whole && r.gap === '12px'), JSON.stringify(rowsShape)); await page.locator('#panel-install .dest-row').first().hover(); await page.waitForTimeout(80); @@ -207,8 +228,10 @@ check('machine surfaces stay midnight in light theme', machineBg === 'rgb(12, 15 await shot('home-light'); await page.reload({ waitUntil: 'networkidle' }); check('theme survives reload before first paint', (await page.evaluate(() => document.documentElement.getAttribute('data-theme'))) === 'light'); -await go('/docs/cli/'); -check('the manual resolves the same theme by the same rule', (await page.evaluate(() => `${document.documentElement.dataset.themePref}/${document.documentElement.dataset.theme}`)) === 'light/light'); +await go(origins.site + '/?dd-theme=light.dark'); +await page.locator('.primary-nav a').filter({ hasText: /^Docs$/u }).click(); +check('the manual resolves the explicitly handed-off theme by the same rule', (await page.evaluate(() => `${document.documentElement.dataset.themePref}/${document.documentElement.dataset.theme}`)) === 'light/light'); +await go('/'); await page.evaluate(() => { localStorage.setItem('diffdevil.theme', 'system'); localStorage.removeItem('diffdevil.theme.next'); }); await go('/'); check('a legacy Automatic value migrates to Auto, with the opposite of its appearance as the next manual side', await (async () => { const t = await themeState(); return t.pref === 'auto' && t.shown === 'dark' && t.stored === 'auto' && t.next === 'light'; })()); @@ -242,7 +265,7 @@ check('live region announced the copy', (await page.locator('[data-live-region]' const skill = page.locator('#agents-skill'); check('skill card starts closed', await skill.locator('.agent-details').isHidden()); await skill.locator('.btn-primary').click(); -check('Add the skill copies the instruction with the configured origin', /Install the diffdevil Agent Skill from https?:\/\/[^ ]+\/skill\/SKILL\.md/u.test(await page.evaluate(() => navigator.clipboard.readText()))); +check('Add the skill copies the instruction with the configured origin', /https?:\/\/[^ ]+\/setup\/skill\.md/u.test(await page.evaluate(() => navigator.clipboard.readText()))); check('Add the skill opened the skill card (proactive expansion)', await skill.locator('.agent-details').isVisible() && (await skill.locator('[data-disclosure]').getAttribute('aria-expanded')) === 'true'); const setup = page.locator('#agents-setup'); await setup.locator('[data-intent="cli"]').click(); @@ -253,7 +276,7 @@ check('selecting a setup chip opens the setup card and swaps the prompt', await await setup.locator('[data-intent="cli"]').press('ArrowRight'); check('arrow keys move the intent selection', (await setup.locator('[data-intent="actions"]').getAttribute('aria-checked')) === 'true'); check('open cards never widen the page and the prompt wraps', (await overflowOf()) <= 0 && await page.evaluate(() => { const pre = document.querySelector('#agents-setup-details pre'); return pre.scrollWidth <= pre.clientWidth; })); -check('the copied setup prompt is one sentence', await page.evaluate(() => { const text = document.querySelector('[data-intent-prompt]').textContent.trim(); return text.endsWith('exactly.') && (text.match(/\. /gu) ?? []).length === 0; })); +check('the copied setup prompt is one sentence', await page.evaluate(() => { const text = document.querySelector('[data-intent-prompt]').textContent.trim(); return /\/setup\/actions\.md/u.test(text) && (text.match(/\. /gu) ?? []).length === 0; })); check('both cards may be open at once', await skill.locator('.agent-details').isVisible() && await setup.locator('.agent-details').isVisible()); await skill.locator('[data-disclosure]').click(); check('explicit disclosure closes the card', await skill.locator('.agent-details').isHidden()); @@ -265,12 +288,12 @@ const routes = await page.evaluate(() => ({ titles: [...document.querySelectorAl check('route cards: 01 run it yourself, 02 browser extension, 03 GitHub App; the Playground card and its PR input are gone', routes.titles.join('|') === 'Run diffdevil yourself|Bring Changed into GitHub|Let it run for you' && routes.prInput === 0, JSON.stringify(routes)); check('the extension card shows the real compact projection with the diffdevil/brand glyph and carries more height than the App card', routes.glyph === 'brand' && /Changed 178 \(\+45 −18 ~115\) size\/M/u.test(routes.projection) && routes.projection.includes('Raw +160 −133') && routes.heights[0] > routes.heights[1], JSON.stringify(routes)); const heroCtas = await page.locator('.hero-ctas a').evaluateAll(links => links.map(a => `${a.textContent.trim()}=${a.getAttribute('href') ?? 'unconfigured'}`)); -check('hero: Add to Chrome first and direct, then CLI / Actions, then the Playground; card 02 says Install the extension; no install button carries a link glyph', (await page.locator('.btn svg[data-icon="external"]').count()) === 0 && (await page.locator('.route-2 .btn').innerText()).trim() === 'Install the extension' && heroCtas.join('|') === 'Add to Chrome=unconfigured|Use the CLI / Actions=/docs/get-started/auto-label-pull-requests/|Try a public PR →=/playground/' && (await page.locator('.hero .eyebrow').innerText()).toUpperCase() === 'COMPOSABLE DIFF ANALYSIS AND AUTOMATION FOR GITHUB AND THE CLI', heroCtas.join('|')); +check('hero: Add to Chrome first and direct, then CLI / Actions, then the Playground; card 02 says Install the extension; no install button carries a link glyph', (await page.locator('.btn svg[data-icon="external"]').count()) === 0 && (await page.locator('.route-2 .btn').innerText()).trim() === 'Install the extension' && heroCtas.join('|') === `Add to Chrome=unconfigured|Use the CLI / Actions=${pageUrl('label-pull-requests')}|Try a public PR →=/playground/` && (await page.locator('.hero .eyebrow').innerText()).toUpperCase() === 'COMPOSABLE DIFF ANALYSIS AND AUTOMATION FOR GITHUB AND THE CLI', heroCtas.join('|')); check('no Add to Chrome or install action is ever routed through a product page', await page.evaluate(() => [...document.querySelectorAll('a')].filter(a => /^(Add to Chrome|Install the App|Install on GitHub|Chrome Web Store)/u.test(a.textContent.trim())).every(a => !['/extension/', '/app/'].includes(a.getAttribute('href') ?? '')))); const order = await page.evaluate(() => [...document.querySelectorAll('main section[data-section]')].map(s => s.dataset.section).join(' ')); check('homepage order puts #extension directly after #measure', order === 'measure extension query policy agents evidence app support', order); const scene = await page.evaluate(() => { const el = document.querySelector('[data-section="extension"] [data-extension-scene]'); const native = el.querySelector('.gh-native'); return { text: el.innerText.replace(/\s+/gu, ' '), glyphBeforeChanged: el.querySelector('.ddx-main').firstElementChild.dataset.icon === 'brand' && el.querySelector('.ddx-main').children[1].textContent === 'Changed', railCells: el.querySelectorAll('[data-scene-anchor] .ddx-cell').length, selected: [...el.querySelectorAll('[data-scene-anchor] .ddx-cell')].findIndex(c => c.classList.contains('is-selected')), nativeOpacity: getComputedStyle(native).opacity, popover: !!el.querySelector('.gh-popover'), files: el.querySelectorAll('.gh-file').length, images: el.querySelectorAll('img, iframe, canvas').length, magenta: [...el.querySelectorAll('*')].some(n => /240, 97, 186|229, 78, 175/u.test(getComputedStyle(n).color + getComputedStyle(n).backgroundColor + getComputedStyle(n).borderColor)) }; }); -check('the extension scene is one semantic GitHub composition: glyph then Changed 178 (+45 −18 ~115), size/M, a five-cell rail with the third cell selected, dimmed native +160 −133, per-file Changed, and the open exact report', scene.glyphBeforeChanged && /Changed 178 \(\+45 −18 ~115\) size\/M/u.test(scene.text) && scene.railCells === 5 && scene.selected === 2 && Number(scene.nativeOpacity) < 0.6 && scene.text.includes('+160 −133') && scene.files === 3 && /src\/Foo\.cs .*Changed 173/u.test(scene.text) && scene.popover && scene.text.includes('= exact') && scene.images === 0 && !scene.magenta, JSON.stringify({ ...scene, text: undefined })); +check('the authored extension illustration preserves its fixture composition, not final UI authority: glyph then Changed 178 (+45 −18 ~115), size/M, a five-cell rail with the third cell selected, dimmed native +160 −133, per-file Changed, and the open exact report', scene.glyphBeforeChanged && /Changed 178 \(\+45 −18 ~115\) size\/M/u.test(scene.text) && scene.railCells === 5 && scene.selected === 2 && Number(scene.nativeOpacity) < 0.6 && scene.text.includes('+160 −133') && scene.files === 3 && /src\/Foo\.cs .*Changed 173/u.test(scene.text) && scene.popover && scene.text.includes('= exact') && scene.images === 0 && !scene.magenta, JSON.stringify({ ...scene, text: undefined })); await page.goto(`http://127.0.0.1:${SITE_PORT}/playground/?pr=example/repository/42`, { waitUntil: 'networkidle' }); // ── playground: PR mode through the real API server ── @@ -388,37 +411,38 @@ await page.locator('#pg-url').fill('https://github.com/example/repository/issues await page.locator('.pg-input form button[type="submit"]').click(); check('non-PR URL is refused client-side without a request', (await page.locator('.pg-input [role="alert"]').innerText()).includes('Not a public github.com pull-request URL')); -// ── docs shell ── -await go('/docs/get-started/auto-label-pull-requests/'); -check('docs page renders the source body with the size workflow', (await page.locator('main').innerText()).includes('pull_request_target')); -check('docs provenance row names the source and tested standing', (await page.locator('.docs-provenance').innerText()).includes('docs/guides/auto-label-pull-requests.md') && (await page.locator('.docs-provenance').innerText()).includes('Example tested')); -check('docs use the global search trigger, placed before the navigation', (await page.locator('[data-search-open]').count()) === 1 && (await page.locator('site-search').count()) === 0 && (await shellOf()).order.startsWith('search-trigger primary-nav')); -const docsShell = await page.evaluate(() => { const b = s => document.querySelector(s).getBoundingClientRect(); const pane = b('.sidebar-pane'), brand = b('.brand-link'), first = b('.sidebar-content summary, .sidebar-content a'); return { paneRight: Math.round(pane.right), paneTop: Math.round(pane.top), headerBottom: Math.round(b('.page > .header').bottom), firstLeft: Math.round(first.left), brandLeft: Math.round(brand.left), paneBg: getComputedStyle(document.querySelector('.sidebar-pane')).backgroundColor, headerBg: getComputedStyle(document.querySelector('.page > .header')).backgroundImage, mainLeft: Math.round(b('.main-pane').left), position: getComputedStyle(document.querySelector('.sidebar-pane')).position, overflow: getComputedStyle(document.querySelector('.sidebar-pane')).overflowY }; }); -check('docs rail starts under the lockup on the page grid, on the lighter ground, and scrolls on its own', docsShell.paneTop === docsShell.headerBottom && Math.abs(docsShell.firstLeft - docsShell.brandLeft) <= 6 && docsShell.paneBg === 'rgb(29, 34, 44)' && docsShell.position === 'fixed' && docsShell.overflow === 'auto' && docsShell.mainLeft >= docsShell.paneRight, JSON.stringify(docsShell)); -check('the rail divider continues through the header', docsShell.headerBg.includes('linear-gradient') && docsShell.headerBg.includes('rgb(29, 34, 44)'), docsShell.headerBg.slice(0, 120)); -check('docs sidebar has the manual groups', (await page.locator('.sidebar-content').textContent()).includes('Policies and detail')); +// ── canonical manual shell and catalogue joins ── +await go(pageUrl('label-pull-requests')); +check('manual first-success route renders the complete size workflow', (await page.locator('main').innerText()).includes('pull_request_target')); +check('manual provenance names canonical source rather than a retired guide', (await page.locator('.docs-provenance').innerText()).includes('docs/manual/start/label-pull-requests.md')); +check('manual uses the global search before navigation', (await page.locator('[data-search-open]').count())===1 && (await page.locator('site-search').count())===0 && (await shellOf()).order.startsWith('search-trigger primary-nav')); +const manualGroups=await page.locator('[data-slw-group]').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('data-slw-group'))); +check('manual sidebar has the accepted six groups and a named navigation landmark', await page.locator('nav[aria-label="Manual navigation"]').count()===1 && ['START','USE DIFFDEVIL','WRITE POLICY','UNDERSTAND','REFERENCE','HELP'].every(label=>manualGroups.includes(label)),manualGroups.join(' | ')); await shot('docs'); -await go('/docs/recipes/policy-recipes/'); -const tryIt = page.locator('a[href^="/playground/?example="]'); -check('docs example links carry a Try it companion', (await tryIt.count()) > 0 && (await tryIt.first().innerText()).includes('Try it in the playground'), String(await tryIt.count())); +await go(pageUrl('changed-lines-and-raw-churn')); +const examples=await page.locator('.sl-markdown-content a[href*="/playground/?example="]').evaluateAll(links=>links.map(link=>link.href)); +check('manual example links name exact catalogue entries and variants',examples.length>0&&examples.every(href=>{const url=new URL(href);return url.searchParams.has('example')&&url.searchParams.has('variant');}),examples.join(' ')); // ── wide layout: the grid stays centred and every route keeps the same edges ── await page.setViewportSize({ width: 1720, height: 900 }); let wideShell; -for (const path of ['/', '/playground/', '/examples/', '/extension/', '/app/', '/docs/cli/']) { +for (const path of ['/', '/playground/', '/examples/', '/extension/', '/app/', pageUrl('cli')]) { await go(path); const shell = await shellOf(); wideShell ??= shell; check(`${path} at 1720: same lockup coordinate and cluster edge as the homepage`, shell.brand.left === wideShell.brand.left && shell.cluster.right === wideShell.cluster.right && shell.brand.left > 200, JSON.stringify(shell)); } -await go('/docs/cli/'); -check('docs at 1720: the rail still starts at the lockup and the ground left of it reaches the viewport edge', await page.evaluate(() => { const pane = document.querySelector('.sidebar-pane').getBoundingClientRect(), brand = document.querySelector('.brand-link').getBoundingClientRect(), first = document.querySelector('.sidebar-content summary, .sidebar-content a').getBoundingClientRect(); return Math.round(pane.left) === 0 && Math.abs(first.left - brand.left) <= 6 && pane.right > brand.right; })); +await go(pageUrl('cli')); +await shot('docs-wide'); +const wideRailVisible = await page.locator('[data-slw-group="START"]').isVisible(); +const wideOverflow = await overflowOf(); +check('manual at 1720 keeps a visible navigation rail without page overflow', wideRailVisible && wideOverflow<=0, `rail visible: ${wideRailVisible}; overflow: ${wideOverflow}`); // ── no page-level horizontal overflow: every route, every qualified width ── // 640 × 2 device pixels is the layout a 1280 window has at 200 % zoom. for (const width of [1440, 1024, 768, 640, 375, 320]) { await page.setViewportSize({ width, height: 900 }); - for (const path of ['/', '/playground/', '/playground/?example=lockfile-scope&variant=all&view=agent', '/playground/?example=lockfile-scope&variant=all&view=github', '/examples/', '/extension/', '/app/', '/privacy/', '/impressum/', '/docs/cli/', '/#agents-setup']) { + for (const path of ['/', '/playground/', '/playground/?example=lockfile-scope&variant=all&view=agent', '/playground/?example=lockfile-scope&variant=all&view=github', '/examples/', '/extension/', '/app/', '/privacy/', '/impressum/', pageUrl('cli'), '/#agents-setup']) { await go(path); if (path.startsWith('/playground/')) await page.waitForSelector('.pg-primary'); check(`${path} has no page-level horizontal overflow at ${width}`, (await overflowOf()) <= 0, String(await overflowOf())); @@ -451,13 +475,13 @@ check('/extension/ is a full page: hero, Changed, policy, report, settings, data check('/extension/ states its real permissions and boundaries and never requires the App', ['api.github.com', 'Incognito', 'not persisted', 'GitHub Enterprise', 'The extension changes your view. The App runs for the repository.'].every(t => ext.text.includes(t)) && !/coming soon|not open yet/iu.test(ext.text)); check('every Add to Chrome on the page is a direct store action, never a link to a product page', ext.add.length >= 2 && ext.add.every(href => href === 'unconfigured' || /^https:\/\/chromewebstore\.google\.com\//u.test(href)), ext.add.join(' ')); const footer = await page.evaluate(() => { const rows = [...document.querySelectorAll('.site-footer nav[aria-label="Learn"] li')].map(li => li.textContent.replace(/\s+/gu, ' ').trim()); const get = [...document.querySelectorAll('.site-footer nav[aria-label="Get"] a')].map(a => `${a.textContent.trim()}=${a.getAttribute('href') ?? 'unconfigured'}`); return { rows, releases: [...document.querySelectorAll('.site-footer a')].find(a => a.textContent === 'Releases').getAttribute('href'), get, appDocs: [...document.querySelectorAll('.site-footer nav[aria-label="Learn"] a')].find(a => a.textContent === 'GitHub App').getAttribute('href') }; }); -check('footer: Releases goes to the manual; Learn carries "Extension · GitHub App" directly above "CLI · Actions · Library API", pointing into the docs', footer.releases === '/docs/releases/' && footer.rows.indexOf('Extension · GitHub App') === footer.rows.indexOf('CLI · Actions · Library API') - 1 && footer.rows.indexOf('Extension · GitHub App') >= 0 && footer.appDocs.startsWith('/docs/github-app/'), JSON.stringify(footer)); +check('footer: Releases goes to the manual; Learn carries "Extension · GitHub App" directly above "CLI · Actions · Library API", pointing into the docs', footer.releases === pageUrl('releases') && footer.rows.indexOf('Extension · GitHub App') === footer.rows.indexOf('CLI · Actions · Library API') - 1 && footer.rows.indexOf('Extension · GitHub App') >= 0 && footer.appDocs === pageUrl('managed-app'), JSON.stringify(footer)); check('footer Get keeps the package and Action first and adds the extension and App with their direct actions', footer.get[0].startsWith('@wolfsblvt/diffdevil=') && footer.get.some(g => g === 'Browser extension=/extension/') && footer.get.some(g => g.startsWith('Chrome Web Store=')) && footer.get.some(g => g === 'GitHub App=/app/') && footer.get.some(g => g.startsWith('Install on GitHub=')), footer.get.join(' | ')); await go('/'); await page.keyboard.press('Control+k'); await page.locator('[data-search-input]').fill('browser extension'); await page.waitForSelector('.search-hit'); -check('search finds the extension page', (await page.locator('.search-hit').evaluateAll(hits => hits.map(h => h.getAttribute('href')))).includes('/extension/')); +check('search finds the extension page', (await page.locator('.search-hit').evaluateAll(hits => hits.map(h => new URL(h.href).pathname))).includes('/extension/')); await page.keyboard.press('Escape'); await page.waitForTimeout(100); check('closing search after the keyboard shortcut returns focus to the visible launcher', await page.evaluate(() => document.activeElement?.matches('[data-search-open]')), await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 120))); @@ -468,20 +492,20 @@ check('examples lead with real pull requests, carry no fixture gallery and no se check('example cards: mark and identity on one row, owner greyed, evidence chip right-aligned', await page.evaluate(() => [...document.querySelectorAll('.ex-card')].every(card => { const head = card.querySelector('.ex-head').getBoundingClientRect(), title = card.querySelector('.ex-title').getBoundingClientRect(), icon = card.querySelector('.ex-title svg').getBoundingClientRect(), chip = card.querySelector('.ex-head .chip-ev').getBoundingClientRect(); const owner = getComputedStyle(card.querySelector('.ex-owner')).color, name = getComputedStyle(card.querySelector('.ex-title a')).color; return icon.bottom <= title.bottom + 1 && icon.top >= title.top - 1 && Math.round(head.right - chip.right) <= 1 && owner !== name; }))); await go('/app/'); check('app page head is the same panel as the homepage', (await page.locator('.app-panel .benefits li').count()) === 6 && (await page.evaluate(() => getComputedStyle(document.querySelector('.app-panel')).backgroundColor)) === 'rgb(12, 15, 23)' && (await page.locator('.app-panel h1').count()) === 1); -check('app page has an honest #history chapter before #permissions with no dashboard imagery', await page.evaluate(() => { const ids = [...document.querySelectorAll('main .eyebrow-link')].map(a => a.id); const section = document.querySelector('[data-section="history"]'); return ids.indexOf('history') > 0 && ids.indexOf('history') < ids.indexOf('permissions') && /in design/u.test(section.innerText) && section.querySelectorAll('img, svg, canvas').length === 0; })); +check('app page has an honest #history chapter before #permissions with no dashboard imagery', await page.evaluate(() => { const ids = [...document.querySelectorAll('main .eyebrow-link')].map(a => a.id); const section = document.querySelector('[data-section="history"]'); return ids.indexOf('history') > 0 && ids.indexOf('history') < ids.indexOf('permissions') && /co-design/u.test(section.innerText) && section.querySelectorAll('img, svg, canvas').length === 0; })); check('app page: comparison blocks sit side by side below the lead; internal strategy prose is gone', await page.evaluate(() => { const [a, b] = [...document.querySelectorAll('[data-section="why"] .choice')].map(el => el.getBoundingClientRect()); return Math.round(a.top) === Math.round(b.top) && Math.round(a.width) === Math.round(b.width) && !document.querySelector('main').innerText.includes('primary pitch'); })); // ── repository links ── for (const path of ['/app/']) { await go(path); - const hrefs = await page.locator('a[href*="/blob/main"]').evaluateAll(links => links.map(a => a.getAttribute('href'))); - check(`${path} repository links carry the separator once`, hrefs.length > 0 && hrefs.every(h => h.includes('/blob/main/') && !h.includes('/blob/main//') && !/\/blob\/main[^/]/u.test(h)), hrefs.slice(0, 3).join(' ')); + const hrefs = await page.locator('a[href*="/blob/"]').evaluateAll(links => links.map(a => a.getAttribute('href'))); + check(`${path} repository links carry the separator once`, hrefs.length > 0 && hrefs.every(h => /\/blob\/[a-f0-9]{40}\//u.test(h) && !/\/blob\/[a-f0-9]{40}\/\//u.test(h)), hrefs.slice(0, 3).join(' ')); } const badge = await page.goto(`http://127.0.0.1:${SITE_PORT}/github-app-logo-512.png`); check('generated GitHub App badge is served by the built site', badge.status() === 200 && (badge.headers()['content-type'] ?? '').includes('image/png')); // ── invariants ── -check('no external requests left the site origin (fonts self-hosted)', external.length === 0, external.slice(0, 5).join(', ')); +check('requests stayed on the two static hosts and local fake API (fonts self-hosted)', external.length === 0, external.slice(0, 5).join(', ')); check('no page errors', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | ')); const unexpectedConsole = consoleErrors.filter(e => !/net::ERR_CONNECTION_REFUSED|Failed to load resource/u.test(e)); check('no unexpected console errors', unexpectedConsole.length === 0, unexpectedConsole.slice(0, 3).join(' | ')); diff --git a/apps/website/search-index.mjs b/apps/website/search-index.mjs index 9c6e2518..13a6680d 100644 --- a/apps/website/search-index.mjs +++ b/apps/website/search-index.mjs @@ -1,34 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFile } from 'node:fs/promises'; -import * as pagefind from 'pagefind'; -import { fileURLToPath } from 'node:url'; -import { join } from 'node:path'; -import { faqRecords } from './faq-content.mjs'; +import { buildManualAndJoin } from '../../tools/public-build.mjs'; -// The locked Starlight toolchain already supplies Pagefind. Use its ESM API directly; -// CommonJS resolution does not support this package's import-only exports. -function checked(result, operation) { - if (result.errors?.length) throw new Error(`${operation}: ${result.errors.join('; ')}`); - return result; -} -/** One search index for selected site/manual bodies and independently linked FAQ answers. */ +/** The website's ordinary build is the entry to both public outputs. Build the + * manual after the site, then produce exactly one typed search index from both. + * Keeping this in the application hook leaves engine/Action package metadata alone. + */ export default function searchIndex() { - return { name: 'diffdevil-search', hooks: { - 'astro:build:done': async ({ dir, logger }) => { - const directory = fileURLToPath(dir); - // A page without this marker (legal pages or the complete FAQ) must not be - // indexed through header/footer text. FAQ entries supply their own body. - const { index } = checked(await pagefind.createIndex({ rootSelector: '[data-pagefind-body]' }), 'Create search index'); - if (!index) throw new Error('Pagefind returned no index.'); - try { - checked(await index.addDirectory({ path: directory }), 'Index site and manual'); - const questions = faqRecords(await readFile(join(directory, 'faq/index.html'), 'utf8')); - for (const question of questions) { - checked(await index.addHTMLFile({ url: question.url, content: question.html }), `Index FAQ ${question.id}`); - } - checked(await index.writeFiles({ outputPath: join(directory, 'pagefind') }), 'Write search index'); - logger.info(`Indexed ${questions.length} individual FAQ answers with site and manual pages.`); - } finally { await pagefind.close(); } + return { name: 'diffdevil-public-docs', hooks: { + 'astro:build:done': ({ logger }) => { + buildManualAndJoin(); + logger.info('Built the manual and mirrored one joined SITE/DOCS/FAQ index.'); }, } }; } diff --git a/apps/website/setup-downloads.test.mjs b/apps/website/setup-downloads.test.mjs new file mode 100644 index 00000000..be25df58 --- /dev/null +++ b/apps/website/setup-downloads.test.mjs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { renderSetup } from './setup-render.mjs'; +import { publishedRelease, validatePublishedRelease, downloadKinds } from './download-source.mjs'; + +const read = name => readFileSync(new URL('../../docs/setup/' + name + '.md', import.meta.url), 'utf8'); +const bindings = { PUBLIC_ORIGIN: 'https://diffdevil.dev', APP_INSTALL_URL: 'https://github.com/apps/test-fixture/installations/new', APP_DASHBOARD_URL: 'https://app.diffdevil.dev' }; + +test('all five canonical setup payloads bind without rewriting GitHub expressions', () => { + for (const name of ['skill', 'cli', 'actions', 'app', 'everything']) { + const source = read(name); + const result = renderSetup(source, bindings); + assert.equal(result.available, true); + assert.doesNotMatch(result.text, /\{\{[A-Z_]+\}\}/u); + assert.deepEqual([...result.text.matchAll(/\$\{\{[^}]+\}\}/gu)].map(m => m[0]), [...source.matchAll(/\$\{\{[^}]+\}\}/gu)].map(m => m[0])); + if (name === 'skill') assert.equal(result.text, source); + } +}); +test('missing App destinations are unavailable rather than placeholders or guessed accounts', () => { + const result = renderSetup(read('app')); + assert.equal(result.available, false); + assert.deepEqual(result.missing, ['APP_INSTALL_URL', 'APP_DASHBOARD_URL']); + assert.doesNotMatch(result.text, /\{\{|github\.com\/apps\//u); + assert.match(result.text, /https:\/\/docs\.diffdevil\.dev\/use\/managed-app\/service\//u); + assert.equal(renderSetup(read('actions')).available, true); +}); +test('unexpected template inputs and malformed public bindings refuse explicitly', () => { + assert.throws(() => renderSetup('{{SECRET}}'), /Unknown setup binding/u); + for (const value of ['javascript:alert(1)', 'https://user:pass@example.org', 'https://example.org/ bad']) { + assert.throws(() => renderSetup('{{APP_INSTALL_URL}}', { APP_INSTALL_URL: value }), /URL binding/u); + } +}); +function releaseFixture() { + return { kind: 'diffdevil.release-manifest', schemaVersion: '1.0', source: {repository: 'Wolfsblvt/diffdevil', commit: 'a'.repeat(40)}, productVersion: '1.0.0', skills: { diffdevil: { version: '0.2.0', sourcePath: 'skills/diffdevil', sourceCommit: 'a'.repeat(40), treeSha256: 'b'.repeat(64) } }, assets: Object.fromEntries(downloadKinds.map(kind => [kind, { name: kind + '.zip', url: 'https://github.com/Wolfsblvt/diffdevil/releases/download/v1.0.0/' + kind + '.zip', sha256: 'c'.repeat(64), size: 123 }])) }; +} +test('downloads consume existing release identities and never infer publication from source', () => { + assert.equal(publishedRelease(''), undefined); + const release = releaseFixture(); + assert.equal(validatePublishedRelease(release), release); + assert.notEqual(release.productVersion, release.skills.diffdevil.version); + release.assets.skill.url = 'https://example.org/fake.zip'; + assert.throws(() => validatePublishedRelease(release), /exact release destination/u); + const incomplete = releaseFixture(); delete incomplete.assets.bundled; + assert.throws(() => validatePublishedRelease(incomplete), /Invalid published release asset/u); +}); diff --git a/apps/website/setup-render.mjs b/apps/website/setup-render.mjs new file mode 100644 index 00000000..8e126601 --- /dev/null +++ b/apps/website/setup-render.mjs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { origins, pageUrl } from '../manual/manifest.mjs'; + +const names = new Set(['PUBLIC_ORIGIN', 'APP_INSTALL_URL', 'APP_DASHBOARD_URL']); +const token = /(? match[1]))]; + for (const name of required) if (!names.has(name)) throw new Error(`Unknown setup binding: ${name}`); + const values = { PUBLIC_ORIGIN: origins.site, ...bindings }; + const missing = required.filter(name => !values[name]); + for (const name of required.filter(name => values[name])) { + let url; + try { url = new URL(values[name]); } catch { throw new Error(`Invalid setup URL binding: ${name}`); } + if (url.protocol !== 'https:' || url.username || url.password || /[\s<>"\x27]/u.test(values[name])) { + throw new Error(`Setup URL binding must be a public HTTPS URL: ${name}`); + } + } + if (missing.length) return { + available: false, missing, + text: '# App setup is not available in this build\n\nThe official installation and dashboard destinations have not both been configured. Do not invent an installation URL, sign in elsewhere, or report successful setup.\n\nRead the complete [managed-service guide](' + pageUrl('managed-service') + ') for the selected product, prerequisites and current availability. The CLI, library and Actions remain usable independently.\n', + }; + const text = source.replace(token, (_match, name) => values[name]); + if ([...text.matchAll(token)].length) throw new Error('Unresolved setup URL binding.'); + return { available: true, missing: [], text }; +} diff --git a/apps/website/src/components/Footer.astro b/apps/website/src/components/Footer.astro index 246fc3f4..a8627432 100644 --- a/apps/website/src/components/Footer.astro +++ b/apps/website/src/components/Footer.astro @@ -21,7 +21,7 @@ const learn: Item[][] = [ [['Policies and detail', docPaths.policies]], [['Agent integration', docPaths.agents]], [[copy.nav.faq, paths.faq]], ]; -const project: Item[] = [['Source on GitHub', GITHUB], ['Releases', docPaths.releases], ['Security', paths.security], ['Licences', paths.licences], ['Privacy and data', paths.privacy], ['Impressum', paths.impressum]]; +const project: Item[] = [['Source on GitHub', GITHUB], ['Releases', docPaths.releases], ['Security', paths.security], ['Licences', paths.licences], ['Product data', docPaths.privacyAndData], ['Website privacy', paths.privacy], ['Impressum', paths.impressum]]; const rel = (href: string) => (href.startsWith('http') ? 'noopener' : undefined); ---