diff --git a/docs/fork-features.md b/docs/fork-features.md new file mode 100644 index 000000000000..9c2e2e8dff55 --- /dev/null +++ b/docs/fork-features.md @@ -0,0 +1,289 @@ +# Fork feature map + +This is the canonical feature inventory consumed by `scripts/fork-features.mjs`. +Each JSON block is one feature. Keep stable ids, a one-line purpose, Issue/PR +links, exact repository-relative paths and literal, case-insensitive watch keywords. +Empty file arrays mean no new files or no independently owned upstream edits. + +`upstreamFiles` assigns every allowlisted path to exactly one primary feature. +`sharedFiles` records other features using that path; all three file arrays take +part in overlap detection. Primary ownership does not imply exclusive behavior. +Some historical allowlist entries (the threads toolkit) are actually fork-new +files; they remain allowlisted and have one owner for compatibility. + +Update this map with each fork feature or deletion. The report is a heuristic: +renamed capabilities or different vocabulary still need human review. Keywords +flag possible overlap, not proof of duplication. See [the routine](fork.md#routine) +for per-match decisions and the absorption PR record. + +## Project Direction + +```json +{ + "id": "direction", + "purpose": "Keep the fork mission and completion criteria explicit.", + "issues": ["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/toolboxmd/t3code/issues/5"], + "prs": ["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/toolboxmd/t3code/pull/5"], + "newFiles": ["MISSION.md", "OBJECTIVE.md", "VISION.md"], + "upstreamFiles": [], + "sharedFiles": [], + "keywords": ["project direction", "VISION.md", "MISSION.md", "OBJECTIVE.md"] +} +``` + +## Product glossary + +```json +{ + "id": "glossary", + "purpose": "Keep Chromeria and its product family terminology consistent.", + "issues": ["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/toolboxmd/t3code/issues/11"], + "prs": ["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/toolboxmd/t3code/pull/11"], + "newFiles": ["GLOSSARY.md"], + "upstreamFiles": [], + "sharedFiles": [], + "keywords": ["Chromeria", "Luxin", "Drafter", "Prism"] +} +``` + +## Chromeria branding + +```json +{ + "id": "branding", + "purpose": "Give desktop and web the Chromeria name, icons and independent desktop identity.", + "issues": [ + "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/toolboxmd/t3code/issues/12", + "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/toolboxmd/t3code/issues/13", + "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/toolboxmd/t3code/issues/14" + ], + "prs": [ + "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/toolboxmd/t3code/pull/12", + "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/toolboxmd/t3code/pull/13", + "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/toolboxmd/t3code/pull/14" + ], + "newFiles": [ + "assets/chromeria/chromeria-icon-1024.png", + "assets/chromeria/chromeria-web-apple-touch-180.png", + "assets/chromeria/chromeria-web-favicon-16x16.png", + "assets/chromeria/chromeria-web-favicon-32x32.png", + "assets/chromeria/chromeria-web-favicon.ico", + "assets/chromeria/chromeria-windows.ico", + "apps/web/public/chromeria-mark.png" + ], + "upstreamFiles": [ + "apps/desktop/package.json", + "apps/desktop/src/app/DesktopAppIdentity.test.ts", + "apps/desktop/src/app/DesktopEnvironment.ts", + "apps/desktop/src/app/DesktopPreReadyPlatform.test.ts", + "scripts/build-desktop-artifact.ts", + "scripts/build-desktop-artifact.test.ts", + "scripts/lib/brand-assets.ts", + "scripts/lib/brand-assets.test.ts", + "apps/web/index.html", + "apps/web/src/bootstrap.test.ts", + "apps/web/src/branding.test.ts", + "apps/web/src/branding.ts", + "apps/web/src/bundledDev.test.ts", + "apps/web/src/components/T3Wordmark.tsx", + "apps/web/src/components/chat/MessagesTimeline.tsx", + "apps/web/src/components/onboarding/WelcomeWizard.tsx", + "apps/web/src/components/settings/IntegrationsSettings.tsx", + "apps/web/src/components/settings/ThemePreviewCircles.tsx", + "apps/web/src/components/sidebar/SidebarChrome.tsx", + "apps/web/src/lib/bootError.ts" + ], + "sharedFiles": [], + "keywords": [ + "branding", + "productName", + "appId", + "wordmark", + "favicon", + "Chromeria", + "auto-update" + ] +} +``` + +## Child threads + +```json +{ + "id": "child-threads", + "purpose": "Spawn child threads, hide them from sidebars and open them from their parent.", + "issues": ["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/toolboxmd/t3code/issues/8"], + "prs": ["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/toolboxmd/t3code/pull/10"], + "newFiles": [ + "apps/server/src/mcp/toolkits/threads/childThreads.test.ts", + "apps/server/src/mcp/toolkits/threads/handlers.ts", + "apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts", + "apps/server/src/mcp/toolkits/threads/subagentThreadId.ts", + "apps/server/src/mcp/toolkits/threads/tools.ts", + "apps/web/src/components/AgentThreadLink.tsx", + "apps/web/src/components/subagentThreads.test.ts", + "apps/web/src/components/subagentThreads.ts" + ], + "upstreamFiles": [ + "apps/server/src/entrypoint.test.ts", + "apps/server/src/mcp/McpHttpServer.ts", + "apps/web/src/components/LegacySidebar.tsx", + "apps/web/src/components/Sidebar.tsx" + ], + "sharedFiles": ["scripts/build-desktop-artifact.ts", "apps/web/src/components/AgentsPanel.tsx"], + "keywords": ["parentThreadId", "child thread", "subagent", "spawn_thread", "sidebar"] +} +``` + +## Fork maintenance and CI + +```json +{ + "id": "fork-maintenance", + "purpose": "Keep the fork stack small, checked and rebasable on GitHub-hosted CI.", + "issues": [ + "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/toolboxmd/t3code/issues/6", + "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/toolboxmd/t3code/issues/20" + ], + "prs": [ + "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/toolboxmd/t3code/pull/9", + "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/toolboxmd/t3code/pull/24" + ], + "newFiles": [ + ".github/workflows/fork.yml", + "docs/fork.md", + "scripts/fork-check.sh", + "scripts/fork-maintenance.test.ts", + "scripts/fork-rebase.sh", + "scripts/fork-upstream-edits.txt", + "docs/fork-features.md", + "scripts/fork-features.mjs" + ], + "upstreamFiles": [ + ".github/workflows/ci.yml", + ".github/workflows/mobile-fingerprint-check.yml", + "knip.jsonc" + ], + "sharedFiles": ["scripts/build-desktop-artifact.ts", "apps/server/src/entrypoint.test.ts"], + "keywords": ["fork", "rebase", "upstream", "blacksmith", "ELECTRON_RUN_AS_NODE", "TMPDIR"] +} +``` + +## Thread scope + +```json +{ + "id": "thread-scope", + "purpose": "Allow explicit same-project supervision while retaining child-only defaults.", + "issues": ["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/toolboxmd/t3code/issues/15"], + "prs": ["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/toolboxmd/t3code/pull/16"], + "newFiles": [], + "upstreamFiles": [ + "apps/server/src/mcp/toolkits/threads/childThreads.test.ts", + "apps/server/src/mcp/toolkits/threads/handlers.ts", + "apps/server/src/mcp/toolkits/threads/tools.ts" + ], + "sharedFiles": [], + "keywords": ["thread scope", "scope", "projectId", "list_threads", "read_thread", "send_message"] +} +``` + +## Agents panel + +```json +{ + "id": "agents-panel", + "purpose": "Separate Prism and direct spawns, nest child threads and navigate parent and siblings.", + "issues": ["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/toolboxmd/t3code/issues/17"], + "prs": ["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/toolboxmd/t3code/pull/18"], + "newFiles": [ + "apps/web/src/components/AgentThreadTree.logic.test.ts", + "apps/web/src/components/AgentThreadTree.logic.ts", + "apps/web/src/components/AgentThreadTree.tsx", + "apps/web/src/components/chat/ThreadParentCrumbs.tsx" + ], + "upstreamFiles": [ + "apps/web/src/components/AgentsPanel.tsx", + "apps/web/src/components/chat/ChatHeader.tsx" + ], + "sharedFiles": [], + "keywords": [ + "AgentsPanel", + "Prism Spawns", + "Direct Spawns", + "child tree", + "breadcrumb", + "parentThreadId", + "sidebar section" + ] +} +``` + +## Prism toolkit and role kits + +```json +{ + "id": "prism-toolkit", + "purpose": "Expose provider capacity and assign scoped thread tools through Prism roles.", + "issues": ["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/toolboxmd/t3code/issues/19"], + "prs": ["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/toolboxmd/t3code/pull/22"], + "newFiles": [ + "apps/server/src/mcp/toolkits/prism/handlers.test.ts", + "apps/server/src/mcp/toolkits/prism/handlers.ts", + "apps/server/src/mcp/toolkits/prism/tools.ts", + "apps/server/src/mcp/toolkits/threads/roles.test.ts", + "apps/server/src/mcp/toolkits/threads/roles.ts", + "apps/server/src/prism/snapshotRoute.test.ts", + "apps/server/src/prism/snapshotRoute.ts", + "packages/contracts/src/prism.test.ts", + "packages/contracts/src/prism.ts", + "packages/contracts/src/prismSnapshot.ts" + ], + "upstreamFiles": [ + "apps/server/src/provider/Drivers/OpenCodeDriver.ts", + "packages/contracts/src/index.ts", + "packages/contracts/src/settings.ts" + ], + "sharedFiles": [ + "apps/server/src/mcp/McpHttpServer.ts", + "apps/server/src/mcp/toolkits/threads/handlers.ts", + "apps/server/src/mcp/toolkits/threads/tools.ts" + ], + "keywords": [ + "prism", + "role kit", + "prismRoles", + "spawn_thread", + "provider snapshot", + "capacity", + "usage limit", + "resume" + ] +} +``` + +## Prism settings page + +```json +{ + "id": "prism-settings", + "purpose": "Configure role preferences and show provider usage and capacity in Settings.", + "issues": ["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/toolboxmd/t3code/issues/21"], + "prs": ["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/toolboxmd/t3code/pull/23"], + "newFiles": [ + "apps/web/src/components/settings/PrismSettings.logic.test.ts", + "apps/web/src/components/settings/PrismSettings.logic.ts", + "apps/web/src/components/settings/PrismSettings.state.test.ts", + "apps/web/src/components/settings/PrismSettings.state.ts", + "apps/web/src/components/settings/PrismSettings.tsx", + "apps/web/src/routes/settings.prism.tsx" + ], + "upstreamFiles": [ + "apps/web/src/components/settings/SettingsSidebarNav.tsx", + "apps/web/src/components/settings/settingsSearch.ts", + "apps/web/src/routeTree.gen.ts" + ], + "sharedFiles": [], + "keywords": ["PrismSettings", "prismRoles", "role preferences", "capacity", "usage", "settings"] +} +``` diff --git a/docs/fork.md b/docs/fork.md index 1d4da4011e88..23cb47c8c6c3 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -20,86 +20,26 @@ patch set further. and then lands with `--force-with-lease` (see Routine). That push is a human-approved step. - **New files first.** Features go in new files and new packages. Edits to - upstream-owned files stay minimal and every edited file is listed under - "Upstream edits" below and in the machine-checked allowlist - `scripts/fork-upstream-edits.txt` (one path per line). + upstream-owned files stay minimal and every edited file has a primary owner + in [the feature map](fork-features.md) and an entry in the machine-checked + allowlist `scripts/fork-upstream-edits.txt` (one path per line). - **Remotes.** `origin` is `toolboxmd/t3code`; `upstream` is `pingdotgg/t3code`. Never push to `upstream`, never send fork commits there (offering extension points upstream is a separate human decision, out of scope for routine maintenance). -## Fork-only content - -New files owned by the fork (no upstream counterpart, always allowed): - -- `VISION.md`, `MISSION.md`, `OBJECTIVE.md`, `GLOSSARY.md`: Project Direction - and project language. -- `assets/chromeria/`, `apps/web/public/chromeria-mark.png`: Chromeria icons. -- `docs/fork.md`: this document. -- `scripts/fork-rebase.sh`: the rebase routine. -- `scripts/fork-check.sh`: the stack-model check run by CI. -- `scripts/fork-maintenance.test.ts`: tests driving both scripts against - throwaway git fixtures (runs with upstream's own test suite). -- `scripts/fork-upstream-edits.txt`: allowlist of upstream files the fork - may modify. -- `.github/workflows/fork.yml`: fork CI (the stack check). - -## Upstream edits - -Upstream-owned files modified by the fork. `scripts/fork-check.sh` fails on -any modified upstream file missing from `scripts/fork-upstream-edits.txt`. - -- Chromeria branding (#12, #13): `apps/desktop/package.json` (product - name), `apps/desktop/src/app/DesktopEnvironment.ts` (names, userData - directory), `scripts/build-desktop-artifact.ts` (app id, artifact name, no - update feed, icons), `scripts/lib/brand-assets.ts` (icon paths), and the - expectations in `apps/desktop/src/app/DesktopAppIdentity.test.ts`, - `apps/desktop/src/app/DesktopPreReadyPlatform.test.ts`, - `scripts/build-desktop-artifact.test.ts` and - `scripts/lib/brand-assets.test.ts`. -- Chromeria name in the web UI (#14): `apps/web/index.html`, - `apps/web/src/branding.ts`, `apps/web/src/lib/bootError.ts`, and the - branding fixture in `apps/web/src/bundledDev.test.ts`, plus the - components `T3Wordmark.tsx`, `chat/MessagesTimeline.tsx`, - `onboarding/WelcomeWizard.tsx`, `settings/IntegrationsSettings.tsx`, - `settings/ThemePreviewCircles.tsx` and `sidebar/SidebarChrome.tsx` under - `apps/web/src/components/`, with expectations in - `apps/web/src/bootstrap.test.ts` and `apps/web/src/branding.test.ts`. -- Child threads and project-scoped supervision (#8, #10, #15): - `apps/server/src/mcp/McpHttpServer.ts` registers the threads MCP toolkit; - `apps/server/src/mcp/toolkits/threads/childThreads.test.ts`, `handlers.ts` - and `tools.ts` implement and test child and same-project listing, reading and - messaging; `apps/web/src/components/AgentsPanel.tsx` links agents to their - child threads; `apps/web/src/components/Sidebar.tsx` and - `apps/web/src/components/LegacySidebar.tsx` hide child threads from the - sidebar. -- Agents panel sections, child tree and breadcrumb (#17): - `apps/web/src/components/AgentsPanel.tsx` splits Prism spawns from direct - spawns and nests child threads under their row; - `apps/web/src/components/chat/ChatHeader.tsx` renders the parent crumb and - sibling menu for a child thread. The logic lives in the fork-owned - `AgentThreadTree.logic.ts`, `AgentThreadTree.tsx` and `chat/ThreadParentCrumbs.tsx`. -- Prism settings (#21): `apps/web/src/components/settings/SettingsSidebarNav.tsx` - and `settingsSearch.ts` register the page below Providers and in settings search; - `apps/web/src/routeTree.gen.ts` is regenerated for the fork-owned route. - The page and its preference logic live in new `PrismSettings` files. -- Prism toolkit and role kits (#19): `packages/contracts/src/settings.ts` - adds the `prismRoles` server setting (project-scoped, with its patch) and - `packages/contracts/src/index.ts` exports the fork-owned `prism.ts` and - `prismSnapshot.ts`; `apps/server/src/mcp/McpHttpServer.ts` registers the - fork-owned Prism toolkit (`toolkits/prism/`) and `GET /api/prism/snapshot` - (`apps/server/src/prism/`); the threads toolkit's `tools.ts` and - `handlers.ts` take `spawn_thread(role)` and enforce each role's thread-tool - scope (`toolkits/threads/roles.ts`); - `apps/server/src/provider/Drivers/OpenCodeDriver.ts` turns on interval - refresh so OpenCode usage windows stay current. -- `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle - self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`. -- `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory - so the test passes under macOS's symlinked `TMPDIR`. -- `.github/workflows/ci.yml`, `.github/workflows/mobile-fingerprint-check.yml`: - `blacksmith-*-ubuntu-2404` runners become `ubuntu-24.04` and - `blacksmith-*-macos-*` becomes `macos-15` (see CI). +## Feature inventory and upstream edits + +[The feature map](fork-features.md) lists every fork feature, its Issue and PR, +new files, edited upstream files, shared files and watch keywords. It is the +canonical inventory used by the checks and the overlap report. Each path in +`scripts/fork-upstream-edits.txt` has exactly one primary feature owner in the +map, including historical entries for fork-new files. Shared edits are recorded +under the other features so the report considers every affected capability. + +When adding or removing a feature, update the map and allowlist in the same PR. +`scripts/fork-check.sh` rejects unallowlisted upstream edits, unmapped allowlist +entries, duplicate owners and malformed feature metadata. ## Chromeria desktop app @@ -152,10 +92,29 @@ Gotchas: ## Routine `scripts/fork-rebase.sh` fetches `upstream`, rebases the current branch's -stack onto `upstream/main`, runs the fork check, optionally runs the full +stack onto `upstream/main` after printing an overlap report, runs the fork check, optionally runs the full proof, and optionally pushes. It reports conflicts per upstream file and never force-pushes a published branch without proof passing in the same run. +Before any rebase, the routine scans every upstream commit from the current +merge base (the last absorbed upstream commit) to the fetched target. It groups +matches by feature, using exact touched paths and case-insensitive literal watch +keywords in commit titles, touched paths and diffs. Shared paths are matched for +each feature that uses them. Merge commits are compared with their first parent. +The report includes full base, target and matching commit SHAs, match reasons and +a decision placeholder for each feature/commit pair. + +Run `scripts/fork-rebase.sh --dry-run` first and retain its output in the +absorption PR, before absorbing. For every match record one decision with a +rationale: **keep ours**, **adopt upstream and delete ours**, or **merge both**. +For adoption, name the fork paths removed; for a merge, describe the single +combined behavior and its proof. Review the report even when Git finds no +conflicts. No matches does not establish that no semantic overlap exists. +Do not approve or land an absorption PR with pending decisions. After conflict +resolution, retain the original report: recomputing from the new merge base +would omit the just-absorbed commits. A report can also be reproduced without +fetching or rebasing using `node scripts/fork-features.mjs report `. + The proof runs in a host-neutral environment, because upstream's tests assume CI's Linux host. On a Mac, run from a T3 thread, about 50 upstream tests otherwise fail without any fork change: T3 Code desktop leaks @@ -230,7 +189,8 @@ runs `scripts/fork-check.sh`, which verifies: - the stack is small (at most 20 commits), - every modification to an upstream-owned file is allowlisted in `scripts/fork-upstream-edits.txt` (new fork-only files are always fine), -- this document exists. +- this document and a valid feature map exist, with exactly one feature owner + for every allowlisted upstream edit. ## Absorption log diff --git a/knip.jsonc b/knip.jsonc index 5e87b4d5bc55..e991e4b3dcb0 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -14,7 +14,7 @@ }, "scripts": { // Knip loads its preprocessor through a CLI option; native verification runs directly. - "entry": ["knip-schemas.ts", "mobile-native-client.ts"], + "entry": ["knip-schemas.ts", "mobile-native-client.ts", "fork-features.mjs"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. diff --git a/scripts/fork-check.sh b/scripts/fork-check.sh index 9eeab392e583..851c9d843550 100755 --- a/scripts/fork-check.sh +++ b/scripts/fork-check.sh @@ -79,8 +79,10 @@ while IFS= read -r path; do done < <(git diff --name-only "$BASE"..HEAD) if [[ "$FAIL" -ne 0 ]]; then - echo "fork-check: FAIL: list the file in scripts/fork-upstream-edits.txt and docs/fork.md, or move the change to a new file." >&2 + echo "fork-check: FAIL: list the file in scripts/fork-upstream-edits.txt and assign its owner in docs/fork-features.md, or move the change to a new file." >&2 exit 1 fi +node "$(dirname "$0")/fork-features.mjs" check + echo "fork-check: OK." diff --git a/scripts/fork-features.mjs b/scripts/fork-features.mjs new file mode 100644 index 000000000000..fd88295d8262 --- /dev/null +++ b/scripts/fork-features.mjs @@ -0,0 +1,158 @@ +// Dependency-free so the stack check also runs before pnpm install in fork CI. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +function git(...args) { + return NodeChildProcess.execFileSync("git", args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); +} + +function loadFeatures(root) { + const text = NodeFS.readFileSync(NodePath.join(root, "docs/fork-features.md"), "utf8"); + const features = [...text.matchAll(/^```json\r?\n([\s\S]*?)^```\s*$/gm)].map((m) => + JSON.parse(m[1]), + ); + if (!features.length) throw new Error("docs/fork-features.md has no feature entries"); + const ids = new Set(); + for (const feature of features) { + if ( + typeof feature.id !== "string" || + !/^[a-z][a-z0-9-]*$/.test(feature.id) || + ids.has(feature.id) + ) { + throw new Error(`invalid or duplicate feature id: ${feature.id}`); + } + ids.add(feature.id); + if (typeof feature.purpose !== "string" || !feature.purpose.trim()) { + throw new Error(`${feature.id}: missing purpose`); + } + for (const field of ["issues", "prs", "newFiles", "upstreamFiles", "sharedFiles", "keywords"]) { + const values = feature[field]; + if (!Array.isArray(values) || values.some((v) => typeof v !== "string" || !v.trim())) { + throw new Error(`${feature.id}: invalid ${field}`); + } + if (new Set(values).size !== values.length) { + throw new Error(`${feature.id}: duplicate ${field}`); + } + } + if (!feature.issues.length || !feature.prs.length || !feature.keywords.length) { + throw new Error(`${feature.id}: Issue, PR and watch keywords are required`); + } + } + const allowlist = NodeFS.readFileSync( + NodePath.join(root, "scripts/fork-upstream-edits.txt"), + "utf8", + ) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + for (const path of new Set(allowlist)) { + const owners = features.filter((feature) => feature.upstreamFiles.includes(path)); + if (owners.length !== 1) { + throw new Error(`${path}: expected exactly one feature owner, found ${owners.length}`); + } + } + for (const feature of features) { + for (const path of feature.upstreamFiles) { + if (!allowlist.includes(path)) + throw new Error(`${feature.id}: path not allowlisted: ${path}`); + } + } + return features; +} + +// Escape commit subjects/keywords before placing them in Markdown table cells. +function cell(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll("|", "|") + .replaceAll("`", "`") + .replaceAll("\n", " "); +} + +function report(features, baseRef, targetRef) { + const base = git("rev-parse", "--verify", `${baseRef}^{commit}`).trim(); + const target = git("rev-parse", "--verify", `${targetRef}^{commit}`).trim(); + git("merge-base", "--is-ancestor", base, target); + const commits = git("rev-list", "--reverse", `${base}..${target}`) + .trim() + .split("\n") + .filter(Boolean); + const matches = new Map(features.map((f) => [f.id, []])); + for (const sha of commits) { + const title = git("show", "-s", "--format=%s", sha).trim(); + // Compare every commit to its first parent, including merge commits. Disable + // renames so both old and new paths are visible, and external diff drivers. + const paths = git("diff", "--no-ext-diff", "--no-renames", "--name-only", "-z", `${sha}^`, sha) + .split("\0") + .filter(Boolean); + const patch = git( + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--unified=0", + `${sha}^`, + sha, + ); + const haystacks = { + title: title.toLowerCase(), + files: paths.join("\n").toLowerCase(), + diff: patch.toLowerCase(), + }; + for (const feature of features) { + const reasons = []; + const files = new Set([ + ...feature.newFiles, + ...feature.upstreamFiles, + ...feature.sharedFiles, + ]); + for (const path of paths) if (files.has(path)) reasons.push(`file: ${path}`); + for (const keyword of feature.keywords) { + const sources = Object.entries(haystacks) + .filter(([, value]) => value.includes(keyword.toLowerCase())) + .map(([source]) => source); + if (sources.length) reasons.push(`keyword (${sources.join(", ")}): ${keyword}`); + } + if (reasons.length) matches.get(feature.id).push({ sha, title, reasons }); + } + } + console.log( + `# Upstream overlap report\n\nBase: ${base}\n\nTarget: ${target}\n\nScanned ${commits.length} upstream commit(s).`, + ); + console.log( + "\nCopy this report into the absorption PR. For EVERY match replace PENDING with one decision and a rationale: keep ours; adopt upstream and delete ours; merge both. Record removed paths or the combined behavior and proof. No matches is not proof of no semantic overlap.", + ); + for (const feature of features) { + console.log(`\n## ${feature.id}\n\n${feature.purpose}`); + const rows = matches.get(feature.id); + if (!rows.length) { + console.log("\nNo matches."); + continue; + } + console.log("\n| Commit | Match evidence | Decision and rationale |\n| --- | --- | --- |"); + for (const row of rows) { + console.log(`| ${row.sha} ${cell(row.title)} | ${cell(row.reasons.join("; "))} | PENDING |`); + } + } +} + +try { + const [command, ...args] = process.argv.slice(2); + const root = git("rev-parse", "--show-toplevel").trim(); + const features = loadFeatures(root); + if (command === "check" && args.length === 0) { + console.log(`fork-features: OK (${features.length} features).`); + } else if (command === "report" && args.length === 2) { + report(features, ...args); + } else { + throw new Error("usage: node scripts/fork-features.mjs check | report "); + } +} catch (error) { + console.error(`fork-features: FAIL: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/fork-maintenance.test.ts b/scripts/fork-maintenance.test.ts index 41ea31c64b44..3d4b65fe0f31 100644 --- a/scripts/fork-maintenance.test.ts +++ b/scripts/fork-maintenance.test.ts @@ -4,18 +4,43 @@ 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 { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; const scriptsDir = import.meta.dirname; const forkCheck = NodePath.join(scriptsDir, "fork-check.sh"); +const forkFeatures = NodePath.join(scriptsDir, "fork-features.mjs"); const forkRebase = NodePath.join(scriptsDir, "fork-rebase.sh"); function git(cwd: string, ...args: Array): string { return NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" }); } +function writeMap(root: string, upstreamFiles = ["scripts/fork-upstream-edits.txt"], extra = {}) { + const feature = { + id: "fixture", + purpose: "Protect the fixture capability.", + issues: ["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/toolboxmd/t3code/issues/20"], + prs: ["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/toolboxmd/t3code/pull/9"], + newFiles: ["new-capability.txt"], + upstreamFiles, + sharedFiles: ["watched.txt"], + keywords: ["child thread", "parentThreadId"], + ...extra, + }; + NodeFS.writeFileSync( + NodePath.join(root, "docs/fork-features.md"), + "# Features\n\n```json\n" + JSON.stringify(feature) + "\n```\n", + ); +} + +const fixtures: Array = []; +afterEach(() => { + for (const root of fixtures.splice(0)) NodeFS.rmSync(root, { recursive: true, force: true }); +}); + function makeFixture(): string { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-fork-check-")); + fixtures.push(root); git(root, "init", "-q", "-b", "work"); git(root, "config", "user.email", "fork-check@test"); git(root, "config", "user.name", "fork-check"); @@ -27,6 +52,7 @@ function makeFixture(): string { "# allowlist\nscripts/fork-upstream-edits.txt\n", ); NodeFS.writeFileSync(NodePath.join(root, "upstream-owned.txt"), "base\n"); + writeMap(root); git(root, "add", "-A"); git(root, "commit", "-qm", "base"); return root; @@ -80,6 +106,7 @@ describe("fork-check", () => { ); git(root, "add", "-A"); git(root, "commit", "-qm", "fork: allowlist the edit"); + writeMap(root, ["scripts/fork-upstream-edits.txt", "upstream-owned.txt"]); const allowed = runCheck(root, "--base", base); expect(allowed.status).toBe(0); expect(allowed.output).toContain("modified (allowlisted): upstream-owned.txt"); @@ -146,3 +173,120 @@ describe("fork-rebase safety rules", () => { } }); }); + +describe("fork feature ownership", () => { + it("rejects an allowlisted path without an owner", () => { + const root = makeFixture(); + writeMap(root, []); + const result = runCheck(root, "--base", baseOf(root)); + expect(result.status).toBe(1); + expect(result.output).toContain("expected exactly one feature owner, found 0"); + }); + + it("rejects two owners and duplicate feature ids", () => { + const root = makeFixture(); + const path = NodePath.join(root, "docs/fork-features.md"); + const original = NodeFS.readFileSync(path, "utf8"); + NodeFS.appendFileSync(path, original.replace('"fixture"', '"second"')); + expect(runCheck(root, "--base", baseOf(root)).output).toContain("found 2"); + NodeFS.writeFileSync(path, original + original); + expect(runCheck(root, "--base", baseOf(root)).output).toContain("duplicate feature id"); + }); + + it("rejects missing or malformed maps and stale ownership", () => { + const root = makeFixture(); + const path = NodePath.join(root, "docs/fork-features.md"); + NodeFS.rmSync(path); + expect(runCheck(root, "--base", baseOf(root)).status).toBe(1); + NodeFS.writeFileSync(path, "```json\n{broken}\n```\n"); + expect(runCheck(root, "--base", baseOf(root)).status).toBe(1); + writeMap(root, undefined, { id: undefined }); + expect(runCheck(root, "--base", baseOf(root)).output).toContain( + "invalid or duplicate feature id", + ); + writeMap(root, ["scripts/fork-upstream-edits.txt", "stale.txt"]); + expect(runCheck(root, "--base", baseOf(root)).output).toContain( + "path not allowlisted: stale.txt", + ); + }); +}); + +function overlap(root: string, base: string, target = "HEAD"): string { + return NodeChildProcess.execFileSync("node", [forkFeatures, "report", base, target], { + cwd: root, + encoding: "utf8", + }); +} + +function commitFile(root: string, path: string, content: string, title: string): string { + NodeFS.writeFileSync(NodePath.join(root, path), content); + git(root, "add", "-A"); + git(root, "commit", "-qm", title); + return baseOf(root); +} + +describe("upstream overlap report", () => { + it("matches titles, changed paths and diffs while excluding old and unrelated commits", () => { + const root = makeFixture(); + commitFile(root, "old.txt", "old", "old child thread"); + const base = baseOf(root); + const title = commitFile(root, "one.txt", "plain", "CHILD THREAD support"); + const path = commitFile(root, "watched.txt", "plain", "touch watched file"); + const diff = commitFile(root, "two.txt", "parentThreadId", "add linkage"); + const added = commitFile(root, "new-capability.txt", "plain", "new capability"); + const unrelated = commitFile(root, "other.txt", "plain", "unrelated"); + const report = overlap(root, base); + for (const sha of [title, path, diff, added]) expect(report).toContain(sha); + expect(report).toContain("keyword (title): child thread"); + expect(report).toContain("file: watched.txt"); + expect(report).toContain("keyword (diff): parentThreadId"); + expect(report).not.toContain("old child thread"); + expect(report).not.toContain(`${unrelated} unrelated`); + expect(report).toContain("PENDING"); + expect(report).toContain("adopt upstream and delete ours"); + expect(overlap(root, base, base)).toContain("No matches."); + }); + + it("matches deleted/renamed paths and merge changes and groups shared files", () => { + const root = makeFixture(); + commitFile(root, "watched.txt", "original", "seed"); + const base = baseOf(root); + const map = NodePath.join(root, "docs/fork-features.md"); + const original = NodeFS.readFileSync(map, "utf8"); + writeMap(root, [], { id: "second" }); + NodeFS.appendFileSync(map, original); + git(root, "mv", "watched.txt", "renamed.txt"); + git(root, "commit", "-qam", "rename capability"); + const renamed = baseOf(root); + git(root, "checkout", "-qb", "side"); + const side = commitFile(root, "side.txt", "parentThreadId", "side linkage"); + git(root, "checkout", "-q", "work"); + commitFile(root, "main.txt", "plain", "advance"); + git(root, "merge", "--no-ff", "-qm", "merge feature", "side"); + const merged = baseOf(root); + const report = overlap(root, base); + expect(report).toContain("## second"); + expect(report).toContain("## fixture"); + for (const sha of [renamed, side, merged]) expect(report).toContain(sha); + expect(report).toContain("file: watched.txt"); + }); + + it("prints the report before rebase and leaves HEAD unchanged in a dry run", () => { + const root = makeFixture(); + const base = baseOf(root); + git(root, "checkout", "-qb", "main"); + commitFile(root, "feature.txt", "parentThreadId", "upstream capability"); + git(root, "checkout", "-q", "work"); + git(root, "remote", "add", "upstream", root); + const report = NodeChildProcess.execFileSync("bash", [forkRebase, "--dry-run"], { + cwd: root, + encoding: "utf8", + }); + expect(report).toContain("# Upstream overlap report"); + expect(report).toContain("upstream capability"); + expect(report.indexOf("# Upstream overlap report")).toBeLessThan( + report.indexOf("dry run; nothing changed"), + ); + expect(baseOf(root)).toBe(base); + }); +}); diff --git a/scripts/fork-rebase.sh b/scripts/fork-rebase.sh index dac55e0d7c67..d1d88ef4ac1e 100755 --- a/scripts/fork-rebase.sh +++ b/scripts/fork-rebase.sh @@ -63,6 +63,9 @@ if [[ "$PENDING" -gt 0 ]]; then git log --oneline "$BASE".."$TARGET" | head -n 20 || true fi +echo "fork-rebase: upstream overlap report (before absorption):" +node "$(dirname "$0")/fork-features.mjs" report "$BASE" "$TARGET" + if [[ "$DRY_RUN" -eq 1 ]]; then echo "fork-rebase: dry run; nothing changed." exit 0 diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index c87d8452e1d9..3f3e98220a0e 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -2,8 +2,8 @@ # One repo-relative path per line. Lines starting with # and blank lines are ignored. # New fork-only files never need listing here; only modifications (or deletions) # of files that already exist on the upstream base must be listed. -# scripts/fork-check.sh enforces this list; keep "Upstream edits" in docs/fork.md -# describing the same files. +# scripts/fork-check.sh enforces this list and exactly one primary feature owner +# per path in docs/fork-features.md. Shared feature usage belongs in sharedFiles. # Chromeria branding (#12, #13) apps/desktop/package.json @@ -55,3 +55,6 @@ packages/contracts/src/settings.ts apps/web/src/components/settings/SettingsSidebarNav.tsx apps/web/src/components/settings/settingsSearch.ts apps/web/src/routeTree.gen.ts + +# Fork maintenance script entry point (#20) +knip.jsonc