From cbdd8c8fabe113bbf49b302a35d73f9677fc0d2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:23:41 +0100 Subject: [PATCH] feat(ci): track maintained fork features --- .github/fork-features.yml | 158 ++++++++++++++++++++++ .github/workflows/fork-ci.yml | 3 + .github/workflows/fork-nightly.yml | 15 ++ docs/README.md | 1 + docs/internals/fork-feature-ledger.md | 59 ++++++++ scripts/check-fork-feature-ledger.ts | 48 +++++++ scripts/fork-feature-ledger.test.ts | 155 +++++++++++++++++++++ scripts/fork-feature-ledger.ts | 188 ++++++++++++++++++++++++++ scripts/package.json | 1 + 9 files changed, 628 insertions(+) create mode 100644 .github/fork-features.yml create mode 100644 docs/internals/fork-feature-ledger.md create mode 100644 scripts/check-fork-feature-ledger.ts create mode 100644 scripts/fork-feature-ledger.test.ts create mode 100644 scripts/fork-feature-ledger.ts diff --git a/.github/fork-features.yml b/.github/fork-features.yml new file mode 100644 index 000000000000..f63d5484e0d4 --- /dev/null +++ b/.github/fork-features.yml @@ -0,0 +1,158 @@ +version: 1 +coverage: incremental + +# Capabilities, not commits. Follow-up fixes belong to the feature they preserve. +features: + - id: completion-sounds + title: Configurable agent completion sounds + status: maintained + prs: [5, 7, 19, 72] + invariants: + - Users can select, preview, and disable the sound played when an agent finishes or awaits input. + - Sound playback follows persisted client settings without firing repeatedly for the same turn state. + implementation_paths: + - apps/web/src/components/TurnCompletionSound.tsx + - apps/web/src/components/settings/SettingsPanels.tsx + - apps/web/src/lib/completionSound.logic.ts + - apps/web/src/lib/completionSound.ts + - packages/contracts/src/settings.ts + upstream_paths: + - apps/web/src/AppRoot.tsx + - apps/web/src/components/settings/SettingsPanels.tsx + - packages/contracts/src/settings.ts + tests: + - apps/desktop/src/settings/DesktopClientSettings.test.ts + - apps/web/src/lib/completionSound.logic.test.ts + - apps/web/src/lib/completionSound.test.ts + - packages/contracts/src/settings.test.ts + upstream: + status: unassessed + tracking: [] + retire_when: Upstream provides configurable completion and awaiting-input sounds with equivalent persistence and playback behavior. + + - id: composer-draft-sync + title: Cross-device composer draft synchronization + status: maintained + prs: [39, 44] + invariants: + - Existing-thread draft text and attachments survive restarts and synchronize without overwriting newer local edits. + - Web, desktop-hosted web, and mobile use the same server-backed draft contract and fork-owned migration history. + implementation_paths: + - apps/mobile/src/state/use-composer-drafts.ts + - apps/server/src/persistence/ComposerDrafts.ts + - apps/server/src/persistence/ForkMigrations/001_ComposerDrafts.ts + - apps/web/src/composerDraftStore.ts + - packages/contracts/src/composerDraft.ts + upstream_paths: + - apps/server/src/server.ts + - apps/server/src/ws.ts + - apps/web/src/components/ChatView.tsx + - apps/web/src/components/chat/ChatComposer.tsx + - packages/client-runtime/src/rpc/client.ts + - packages/contracts/src/rpc.ts + tests: + - apps/mobile/src/state/use-composer-drafts.test.ts + - apps/server/src/persistence/ComposerDrafts.test.ts + - apps/server/src/persistence/ForkMigrations.test.ts + - apps/web/src/composerDraftStore.test.ts + upstream: + status: unassessed + tracking: [] + retire_when: Upstream synchronizes equivalent composer draft state across clients and safely migrates installations carrying the fork schema. + + - id: github-outage-status + title: GitHub outage status in the sidebar + status: maintained + prs: [14, 136] + invariants: + - Enabled clients distinguish GitHub incidents from missing or malformed status responses. + - The sidebar notice reports affected services without blocking ordinary source-control work. + implementation_paths: + - apps/web/src/components/settings/SettingsPanels.tsx + - apps/web/src/components/sidebar/GitHubStatusNotice.tsx + - apps/web/src/githubStatus.ts + - packages/contracts/src/settings.ts + upstream_paths: + - apps/web/src/components/settings/SettingsPanels.tsx + - apps/web/src/components/sidebar/SidebarChrome.tsx + - packages/contracts/src/settings.ts + tests: + - apps/desktop/src/settings/DesktopClientSettings.test.ts + - apps/web/src/githubStatus.test.ts + - packages/contracts/src/settings.test.ts + upstream: + status: unassessed + tracking: [] + retire_when: Upstream exposes equivalent configurable GitHub incident reporting in the primary navigation. + + - id: integrated-browser-links + title: Open local development links in the integrated browser + status: maintained + prs: [107, 145, 153, 156] + invariants: + - Loopback links opened from chat or terminal activity resolve on the environment machine in the integrated browser. + - Unsupported links retain an external-browser path and never strand the user behind an unavailable preview runtime. + implementation_paths: + - apps/desktop/src/preview/BrowserSession.ts + - apps/server/src/preview/TerminalBrowserOpen.ts + - apps/web/src/components/chat/loopbackLinkPreview.ts + - apps/web/src/components/preview/openTerminalLinkInPreview.ts + upstream_paths: + - apps/desktop/src/preview/Manager.ts + - apps/server/src/http.ts + - apps/server/src/terminal/Manager.ts + - apps/web/src/components/ChatMarkdown.tsx + - apps/web/src/components/ThreadTerminalDrawer.tsx + tests: + - apps/desktop/src/preview/BrowserSession.test.ts + - apps/server/src/preview/TerminalBrowserOpen.test.ts + - apps/web/src/components/chat/loopbackLinkPreview.test.ts + - apps/web/src/components/preview/openTerminalLinkInPreview.test.ts + upstream: + status: unassessed + tracking: [] + retire_when: Upstream routes local chat and terminal links through an environment-aware integrated browser with an external fallback. + + - id: project-filter-continuity + title: Preserve project filters while navigating and creating threads + status: maintained + prs: [83, 162] + invariants: + - Reopening the sidebar does not discard the user's project filter. + - New-thread actions follow the visible project scope without unexpectedly switching the active thread's project. + implementation_paths: + - apps/web/src/components/CommandPalette.tsx + - apps/web/src/components/Sidebar.tsx + - apps/web/src/lib/chatThreadActions.ts + - apps/web/src/sidebarProjectScopeStore.ts + upstream_paths: + - apps/web/src/components/AppSidebarLayout.tsx + - apps/web/src/components/CommandPalette.tsx + - apps/web/src/components/Sidebar.tsx + tests: + - apps/web/src/lib/chatThreadActions.test.ts + upstream: + status: unassessed + tracking: [] + retire_when: Upstream preserves project scope across sidebar state and every new-thread entry point. + + - id: run-shell-code-blocks + title: Run completed shell code blocks in a terminal + status: maintained + prs: [97] + invariants: + - Completed recognized shell fences expose a Run in terminal action with the exact displayed command. + - Incomplete or non-shell code blocks never expose the terminal action. + implementation_paths: + - apps/web/src/components/ChatMarkdown.tsx + - apps/web/src/components/chat/MessagesTimeline.tsx + upstream_paths: + - apps/web/src/components/ChatMarkdown.tsx + - apps/web/src/components/chat/MessagesTimeline.tsx + tests: + - apps/web/src/components/ChatMarkdown.test.tsx + - apps/web/src/components/chat/MessagesTimeline.test.tsx + upstream: + status: unassessed + tracking: [] + retire_when: Upstream offers equivalent guarded terminal execution for completed shell code blocks. diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index 943fdc429e93..b86b9803199d 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -113,6 +113,9 @@ jobs: - name: Check run: vp check + - name: Check fork feature ledger + run: vp run --filter @t3tools/scripts ledger:check + - name: Typecheck run: vpr typecheck diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml index 5771113b1cca..34ec0b6163ab 100644 --- a/.github/workflows/fork-nightly.yml +++ b/.github/workflows/fork-nightly.yml @@ -167,6 +167,7 @@ jobs: echo "ref=$candidate_ref" echo "fork_ref=$fork_ref" echo "main_ref=$main_ref" + echo "old_upstream_ref=$OLD_UPSTREAM_REF" echo "upstream_ref=$upstream_ref" } >> "$GITHUB_OUTPUT" @@ -237,6 +238,20 @@ jobs: cache: true run-install: true + - name: Review upstream overlap with fork features + if: steps.candidate.outputs.has_changes == 'true' + continue-on-error: true + env: + OLD_UPSTREAM_REF: ${{ steps.candidate.outputs.old_upstream_ref }} + UPSTREAM_REF: ${{ steps.candidate.outputs.upstream_ref }} + shell: bash + run: | + set -euo pipefail + changed_paths="$RUNNER_TEMP/upstream-changed-paths.txt" + git diff --name-only --no-renames \ + "$OLD_UPSTREAM_REF..$UPSTREAM_REF" > "$changed_paths" + vp run --filter @t3tools/scripts ledger:check -- --changed-paths "$changed_paths" + - name: Ensure Electron runtime is installed if: steps.candidate.outputs.has_changes == 'true' run: vp run --filter @t3tools/desktop ensure:electron diff --git a/docs/README.md b/docs/README.md index e8eb1330ba5d..ca2d919c1a59 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Workspace layout](./internals/workspace-layout.md) - [Glossary](./internals/glossary.md) - [Scripts](./internals/scripts.md) +- [Fork feature ledger](./internals/fork-feature-ledger.md) - [Connection runtime](./internals/connection-runtime.md) - [Providers](./internals/providers.md) - [Remote environments](./internals/remote.md) diff --git a/docs/internals/fork-feature-ledger.md b/docs/internals/fork-feature-ledger.md new file mode 100644 index 000000000000..3e1c4ad08e25 --- /dev/null +++ b/docs/internals/fork-feature-ledger.md @@ -0,0 +1,59 @@ +# Fork feature ledger + +The fork feature ledger at `.github/fork-features.yml` records the behavioral capabilities that the +fork deliberately maintains beyond upstream. It complements the commit patch stack and the generated +fork-features issue: + +- commits preserve how the implementation changed; +- the generated issue summarizes what users receive; +- the ledger states what behavior maintainers intend to preserve, where upstream changes can affect + it, and which tests provide evidence. + +The ledger is capability-oriented. A feature and its later fixes share one entry and one stable ID. +CI validates the file with `vp run --filter @t3tools/scripts ledger:check`. + +## Fields + +- `id`: stable lower-kebab-case capability ID. Do not rename it when implementation details move. +- `title`: short maintainer-facing capability name. +- `status`: `maintained`, `review-needed`, or `retiring`. +- `prs`: fork pull requests that introduced or materially repaired the capability. +- `invariants`: observable behavior that must remain true after upstream integration. +- `implementation_paths`: fork implementation files whose removal or rename must update the ledger. +- `upstream_paths`: files shared with upstream whose upstream changes should prompt semantic review. +- `tests`: focused evidence for the invariants. These files must continue to exist. +- `upstream.status`: `unassessed`, `tracking`, `partial`, or `equivalent`. +- `upstream.tracking`: upstream issues, pull requests, or commits used for an assessed status. +- `upstream.retire_when`: the condition under which the fork implementation can be removed. + +Paths are exact files rather than broad directories or globs. Implementation and test paths must +exist in the fork, making local renames and removals fail validation instead of silently weakening the +ledger. Upstream paths are audited when an entry is added to ensure they exist on the current upstream +base; Fork Nightly disables rename detection when diffing upstream so a moved path is reported as both +the watched deletion and a new addition. + +## Workflow + +Add a ledger entry when a pull request creates a new maintained divergence. Add follow-up pull request +numbers, invariants, paths, and test evidence to the existing entry when repairing or extending one. +Keep implementation and upstream paths distinct, entries sorted by ID, and values within structured +lists sorted. + +During an upstream rebase, review a capability when upstream changes one of its upstream paths, when +its tests need conflict resolution, or when the patch range-diff changes. Record upstream evidence +before changing `upstream.status` from `unassessed`. Fork Nightly compares the old and new upstream +commits and writes warnings plus a workflow summary section for every exact upstream-path overlap. +These warnings identify where judgment is needed; they do not claim that an overlap is a behavioral +conflict. Fork CI validation is blocking, while the Nightly overlap step is deliberately advisory so +a reporting failure cannot prevent an otherwise verified release. + +When upstream provides overlapping behavior, mark the entry `review-needed` and compare the ledger's +invariants rather than implementation shape. Retire the fork patch only after upstream satisfies the +documented condition and the resulting stack passes the focused tests. Remove the ledger entry in the +same reviewed change that removes the final fork-owned behavior. + +## Coverage + +Coverage begins incrementally with cross-surface and frequently conflicted capabilities. The ledger's +`coverage: incremental` marker is an explicit statement that unlisted fork behavior still exists; it +must not be interpreted as a complete inventory until a reviewed backfill says otherwise. diff --git a/scripts/check-fork-feature-ledger.ts b/scripts/check-fork-feature-ledger.ts new file mode 100644 index 000000000000..fd0c6a3f032a --- /dev/null +++ b/scripts/check-fork-feature-ledger.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { + findForkFeatureOverlaps, + loadForkFeatureLedger, + renderForkFeatureOverlapSummary, + validateForkFeatureLedger, +} from "./fork-feature-ledger.ts"; + +const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); + +try { + const ledger = loadForkFeatureLedger(repoRoot); + const errors = validateForkFeatureLedger(ledger, repoRoot); + if (errors.length > 0) { + process.stderr.write(`${errors.map((error) => `- ${error}`).join("\n")}\n`); + process.exitCode = 1; + } else { + process.stdout.write(`Validated ${ledger.features.length} fork feature ledger entries.\n`); + const changedPathsFlagIndex = process.argv.indexOf("--changed-paths"); + if (changedPathsFlagIndex !== -1) { + const changedPathsFile = process.argv[changedPathsFlagIndex + 1]; + if (changedPathsFile === undefined) throw new Error("--changed-paths requires a file path."); + const changedPaths = NodeFS.readFileSync(changedPathsFile, "utf8") + .split(/\r?\n/u) + .filter((path) => path.length > 0); + const overlaps = findForkFeatureOverlaps(ledger, changedPaths); + const summary = renderForkFeatureOverlapSummary(overlaps); + process.stdout.write(summary); + if (process.env.GITHUB_STEP_SUMMARY !== undefined) { + NodeFS.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } + for (const { feature, paths } of overlaps) { + process.stdout.write( + `::warning title=Upstream touched fork feature::${feature.id}: ${paths.join(", ")}\n`, + ); + } + } + } +} catch (error) { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; +} diff --git a/scripts/fork-feature-ledger.test.ts b/scripts/fork-feature-ledger.test.ts new file mode 100644 index 000000000000..b7df89d39a1f --- /dev/null +++ b/scripts/fork-feature-ledger.test.ts @@ -0,0 +1,155 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { assert, describe, it } from "@effect/vitest"; +import { parse } from "yaml"; + +import { + decodeForkFeatureLedger, + findForkFeatureOverlaps, + loadForkFeatureLedger, + validateForkFeatureLedger, + type ForkFeatureLedger, +} from "./fork-feature-ledger.ts"; + +const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const cliPath = NodePath.resolve(repoRoot, "scripts/check-fork-feature-ledger.ts"); +const nightlyWorkflowPath = NodePath.resolve(repoRoot, ".github/workflows/fork-nightly.yml"); + +function validate(ledger: ForkFeatureLedger): string { + return validateForkFeatureLedger(ledger, repoRoot).join("\n"); +} + +describe("fork feature ledger", () => { + it("keeps the checked-in capability evidence valid", () => { + const ledger = loadForkFeatureLedger(repoRoot); + + assert.equal(ledger.coverage, "incremental"); + assert.isAtLeast(ledger.features.length, 6); + assert.deepEqual(validateForkFeatureLedger(ledger, repoRoot), []); + }); + + it("rejects unsupported ledger versions during decoding", () => { + assert.throws(() => + decodeForkFeatureLedger(` +version: 2 +coverage: incremental +features: [] +`), + ); + }); + + it("rejects duplicate identities and unsorted evidence", () => { + const ledger = loadForkFeatureLedger(repoRoot); + const first = ledger.features[0]!; + const second = ledger.features[1]!; + const invalid = { + ...ledger, + features: [ + { ...first, prs: first.prs.toReversed() }, + { ...second, id: first.id }, + ...ledger.features.slice(2), + ], + } satisfies ForkFeatureLedger; + + const errors = validate(invalid); + + assert.include(errors, `Duplicate feature id: ${first.id}`); + assert.include(errors, `${first.id}.prs must be sorted.`); + }); + + it("rejects missing evidence files and unsupported upstream assessments", () => { + const ledger = loadForkFeatureLedger(repoRoot); + const first = ledger.features[0]!; + const invalid = { + ...ledger, + features: [ + { + ...first, + tests: ["apps/web/src/removed-feature.test.ts"], + upstream: { ...first.upstream, status: "tracking" as const }, + }, + ...ledger.features.slice(1), + ], + } satisfies ForkFeatureLedger; + + const errors = validate(invalid); + + assert.include(errors, `${first.id}.tests does not name an existing file`); + assert.include(errors, `${first.id}.upstream.tracking must cite evidence`); + }); + + it("maps changed upstream paths to the capabilities needing review", () => { + const ledger = loadForkFeatureLedger(repoRoot); + + const overlaps = findForkFeatureOverlaps(ledger, [ + "apps/web/src/AppRoot.tsx", + "apps/web/src/components/sidebar/SidebarChrome.tsx", + "apps/web/src/untracked.ts", + ]); + + assert.deepEqual( + overlaps.map(({ feature, paths }) => ({ id: feature.id, paths })), + [ + { id: "completion-sounds", paths: ["apps/web/src/AppRoot.tsx"] }, + { + id: "github-outage-status", + paths: ["apps/web/src/components/sidebar/SidebarChrome.tsx"], + }, + ], + ); + }); + + it("runs the CLI and writes advisory overlap evidence", () => { + const fixtureRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-fork-ledger-")); + try { + const changedPathsFile = NodePath.join(fixtureRoot, "changed-paths.txt"); + const summaryFile = NodePath.join(fixtureRoot, "summary.md"); + NodeFS.writeFileSync(changedPathsFile, "apps/web/src/AppRoot.tsx\n"); + + const result = NodeChildProcess.spawnSync( + process.execPath, + [cliPath, "--changed-paths", changedPathsFile], + { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, GITHUB_STEP_SUMMARY: summaryFile }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "Validated"); + assert.include(result.stdout, "Upstream touched fork feature::completion-sounds"); + assert.include(NodeFS.readFileSync(summaryFile, "utf8"), "completion-sounds"); + } finally { + NodeFS.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("wires rename-aware advisory overlap review into Fork Nightly", () => { + const workflow = parse(NodeFS.readFileSync(nightlyWorkflowPath, "utf8")) as { + readonly jobs: { + readonly prepare: { + readonly steps: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly run?: string; + readonly "continue-on-error"?: boolean; + }>; + }; + }; + }; + const candidate = workflow.jobs.prepare.steps.find((step) => step.id === "candidate"); + const review = workflow.jobs.prepare.steps.find( + (step) => step.name === "Review upstream overlap with fork features", + ); + + assert.include(candidate?.run ?? "", 'echo "old_upstream_ref=$OLD_UPSTREAM_REF"'); + assert.equal(review?.["continue-on-error"], true); + assert.include(review?.run ?? "", "git diff --name-only --no-renames"); + assert.include(review?.run ?? "", "ledger:check -- --changed-paths"); + }); +}); diff --git a/scripts/fork-feature-ledger.ts b/scripts/fork-feature-ledger.ts new file mode 100644 index 000000000000..69a8be903063 --- /dev/null +++ b/scripts/fork-feature-ledger.ts @@ -0,0 +1,188 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { fromYaml } from "@t3tools/shared/schemaYaml"; +import * as Schema from "effect/Schema"; + +const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); + +const UpstreamDisposition = Schema.Struct({ + status: Schema.Literals(["unassessed", "tracking", "partial", "equivalent"]), + tracking: Schema.Array(Schema.NonEmptyString), + retire_when: Schema.NonEmptyString, +}); + +export const ForkFeatureLedgerEntry = Schema.Struct({ + id: Schema.NonEmptyString, + title: Schema.NonEmptyString, + status: Schema.Literals(["maintained", "review-needed", "retiring"]), + prs: Schema.Array(PositiveInteger), + invariants: Schema.Array(Schema.NonEmptyString), + implementation_paths: Schema.Array(Schema.NonEmptyString), + upstream_paths: Schema.Array(Schema.NonEmptyString), + tests: Schema.Array(Schema.NonEmptyString), + upstream: UpstreamDisposition, +}); +export type ForkFeatureLedgerEntry = typeof ForkFeatureLedgerEntry.Type; + +export const ForkFeatureLedger = Schema.Struct({ + version: Schema.Literal(1), + coverage: Schema.Literal("incremental"), + features: Schema.Array(ForkFeatureLedgerEntry), +}); +export type ForkFeatureLedger = typeof ForkFeatureLedger.Type; + +export interface ForkFeatureOverlap { + readonly feature: ForkFeatureLedgerEntry; + readonly paths: ReadonlyArray; +} + +export const ledgerRelativePath = ".github/fork-features.yml"; +const ledgerSchema = fromYaml(ForkFeatureLedger); + +function duplicates(values: ReadonlyArray): ReadonlyArray { + const seen = new Set(); + const repeated = new Set(); + for (const value of values) { + if (seen.has(value)) repeated.add(value); + seen.add(value); + } + return [...repeated]; +} + +function isSorted(values: ReadonlyArray): boolean { + return values.every((value, index) => index === 0 || values[index - 1]! <= value); +} + +function validatePath( + repoRoot: string, + featureId: string, + field: string, + path: string, +): string | null { + const segments = path.split("/"); + if ( + NodePath.isAbsolute(path) || + path.includes("\\") || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + return `${featureId}.${field} contains invalid repository path: ${path}`; + } + + const absolutePath = NodePath.resolve(repoRoot, path); + const relativePath = NodePath.relative(repoRoot, absolutePath); + if (relativePath.startsWith("..") || NodePath.isAbsolute(relativePath)) { + return `${featureId}.${field} escapes the repository: ${path}`; + } + if (!NodeFS.existsSync(absolutePath) || !NodeFS.statSync(absolutePath).isFile()) { + return `${featureId}.${field} does not name an existing file: ${path}`; + } + return null; +} + +export function decodeForkFeatureLedger(contents: string): ForkFeatureLedger { + return Schema.decodeUnknownSync(ledgerSchema)(contents); +} + +export function validateForkFeatureLedger( + ledger: ForkFeatureLedger, + repoRoot: string, +): ReadonlyArray { + const errors: Array = []; + const ids = ledger.features.map((feature) => feature.id); + + if (ledger.features.length === 0) errors.push("features must contain at least one entry."); + if (!isSorted(ids)) errors.push("features must be sorted by id."); + for (const id of duplicates(ids)) errors.push(`Duplicate feature id: ${id}`); + + for (const feature of ledger.features) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(feature.id)) { + errors.push(`${feature.id}.id must use lower-kebab-case.`); + } + if (feature.prs.length === 0) errors.push(`${feature.id}.prs must not be empty.`); + if (feature.invariants.length === 0) errors.push(`${feature.id}.invariants must not be empty.`); + if (feature.implementation_paths.length === 0) + errors.push(`${feature.id}.implementation_paths must not be empty.`); + if (feature.tests.length === 0) errors.push(`${feature.id}.tests must not be empty.`); + if (feature.upstream_paths.length === 0) + errors.push(`${feature.id}.upstream_paths must not be empty.`); + + for (const [field, values] of [ + ["prs", feature.prs], + ["implementation_paths", feature.implementation_paths], + ["tests", feature.tests], + ["upstream_paths", feature.upstream_paths], + ] as const) { + const comparableValues: ReadonlyArray = values; + if (!isSorted(comparableValues)) errors.push(`${feature.id}.${field} must be sorted.`); + for (const value of duplicates(comparableValues)) { + errors.push(`${feature.id}.${field} contains duplicate value: ${value}`); + } + } + + for (const path of feature.tests) { + const error = validatePath(repoRoot, feature.id, "tests", path); + if (error !== null) errors.push(error); + } + for (const path of feature.implementation_paths) { + const error = validatePath(repoRoot, feature.id, "implementation_paths", path); + if (error !== null) errors.push(error); + } + for (const path of feature.upstream_paths) { + const error = validatePath(repoRoot, feature.id, "upstream_paths", path); + if (error !== null) errors.push(error); + } + + for (const trackingUrl of feature.upstream.tracking) { + try { + const url = new URL(trackingUrl); + if (url.protocol !== "https:") throw new Error("not HTTPS"); + } catch { + errors.push(`${feature.id}.upstream.tracking contains invalid HTTPS URL: ${trackingUrl}`); + } + } + if (!isSorted(feature.upstream.tracking)) { + errors.push(`${feature.id}.upstream.tracking must be sorted.`); + } + for (const trackingUrl of duplicates(feature.upstream.tracking)) { + errors.push(`${feature.id}.upstream.tracking contains duplicate value: ${trackingUrl}`); + } + if (feature.upstream.status === "unassessed" && feature.upstream.tracking.length > 0) { + errors.push(`${feature.id}.upstream.tracking requires an assessed upstream status.`); + } + if (feature.upstream.status !== "unassessed" && feature.upstream.tracking.length === 0) { + errors.push(`${feature.id}.upstream.tracking must cite evidence for an assessed status.`); + } + } + + return errors; +} + +export function loadForkFeatureLedger(repoRoot: string): ForkFeatureLedger { + const ledgerPath = NodePath.resolve(repoRoot, ledgerRelativePath); + return decodeForkFeatureLedger(NodeFS.readFileSync(ledgerPath, "utf8")); +} + +export function findForkFeatureOverlaps( + ledger: ForkFeatureLedger, + changedPaths: ReadonlyArray, +): ReadonlyArray { + const changed = new Set(changedPaths); + return ledger.features.flatMap((feature) => { + const paths = feature.upstream_paths.filter((path) => changed.has(path)); + return paths.length === 0 ? [] : [{ feature, paths }]; + }); +} + +export function renderForkFeatureOverlapSummary( + overlaps: ReadonlyArray, +): string { + if (overlaps.length === 0) return "No tracked fork feature upstream paths changed.\n"; + const entries = overlaps.map( + ({ feature, paths }) => `- \`${feature.id}\`: ${feature.title} — ${paths.join(", ")}`, + ); + return `## Fork feature overlap review\n\n${entries.join("\n")}\n`; +} diff --git a/scripts/package.json b/scripts/package.json index 14c4ea98e9b2..e76fb23d335b 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "ledger:check": "node check-fork-feature-ledger.ts", "typecheck": "tsgo --noEmit", "test": "vp test run" },