mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
9ee2e83287
* feat(review): make the CallDiff runtime a strictly opt-in, in-UI install
The merged CallDiff integration eagerly installed a ~784MB runtime for
every user at install time, for a feature that is off by default. The
runtime is now strictly opt-in and the opt-in lives in the review UI:
toggle Call flow, click Install in the panel, watch staged progress, and
use the analysis in the same session.
Installers: the default sequence no longer installs the runtime. Opt in
with --with-call-flow (PowerShell: -WithCallFlow),
PLANNOTATOR_INSTALL_CALLDIFF=1, or { "installCallFlow": true } in
config.json (flag > env > config). PLANNOTATOR_SKIP_CALLDIFF_INSTALL is
deleted; --minimal keeps excluding the runtime; the installer prints an
honest note pointing at the in-app install. The headless CLI path
(plannotator install-runtime call-flow) is unchanged.
Server (both runtimes, contract-identical): POST /api/call-flow/install
starts installCallFlowRuntime() in the background via a single-flighted
coordinator (concurrent POSTs join the in-flight install), runs a
Node 22+ preflight before any download (distinct node-unavailable
error), and rejects cross-origin POSTs with 403. GET
/api/call-flow/install-status reports idle/running/done/error with
stage: downloading, verifying, installing-deps, building. Install
completion invalidates the 30s runtime probe cache so the next
capability advert resolves available without a server restart.
Client: the Call flow Dock's runtime-missing state is now the opt-in
funnel with an honest disclosure (about 800 MB on disk, Node 22+,
one-time), staged reduced-motion-safe progress, and error + retry with
a no-node hint. On done the advert is refreshed through
POST /api/review-analysis and the existing available-change refetch
starts the analysis for the current snapshot with no reload. The intro
dialog and Settings toggle note the separate first-use runtime.
Docs: AGENTS.md env table + Review Server API table, marketing
environment-variables / installation / ui-settings / code-review /
api-endpoints pages, and the CallDiff ADR runtime-boundary and server
contract sections.
* test(review): stop leaking PLANNOTATOR_DATA_DIR from the install endpoint tests
The call-flow install endpoint tests overrode PLANNOTATOR_DATA_DIR at
module-eval time and never restored it. bun runs CI's full suite in one
process and evaluates every test file's module before running tests,
while Pi's generated/storage.ts caches its data dir at import time; the
override therefore made storage's cached dir and later files' live
getPlannotatorDataDir() calls disagree, failing the Pi annotate-history
unwritable-dir test and both durable-submit-record tests.
An afterAll restore alone is not enough: it reproduces the same three
failures with the mismatch inverted (storage caches the leaked dir at
module eval, tests then run against the restored one). The env var is
now never touched at module-eval time at all; it changes only inside
tests and is restored to its original value in afterEach, exactly like
the PORT/PATH pattern. The config writes the advert tests persist
through the process's frozen config module are snapshotted at load and
restored in afterAll so a standalone run never flips a real
config.json setting, and the process-global scope of the mock.module
seams is documented.
Regression proof (previously failing in either mismatch direction, now
green in both orderings):
bun test packages/server/call-flow-install-endpoint.test.ts \
apps/pi-extension/server/annotate-history.test.ts \
apps/pi-extension/server/annotate-submission.test.ts
* feat(review): install CallDiff grammars selectively
* fix(review): harden CallDiff worker environment
* fix(review): close CallDiff verification gaps
163 lines
8.6 KiB
TypeScript
163 lines
8.6 KiB
TypeScript
import { parseDiffFilePathLines, parseDiffGitHeader } from "./diff-paths";
|
|
|
|
/** Stable identifier for one CallDiff language family. */
|
|
export type CallFlowLanguageId =
|
|
| "javascript-typescript"
|
|
| "python"
|
|
| "go"
|
|
| "rust"
|
|
| "java"
|
|
| "ruby"
|
|
| "c"
|
|
| "cpp"
|
|
| "csharp"
|
|
| "php"
|
|
| "kotlin"
|
|
| "swift"
|
|
| "scala"
|
|
| "lua"
|
|
| "elixir"
|
|
| "bash"
|
|
| "haskell"
|
|
| "zig"
|
|
| "solidity"
|
|
| "ocaml";
|
|
|
|
export const CALL_FLOW_CORE_LANGUAGE_ID: CallFlowLanguageId = "javascript-typescript";
|
|
|
|
export interface CallFlowLanguageDefinition {
|
|
readonly id: CallFlowLanguageId;
|
|
readonly label: string;
|
|
readonly extensions: readonly string[];
|
|
/** Core ships with CallDiff; packs are independently installable. */
|
|
readonly kind: "core" | "pack";
|
|
readonly packageName?: string;
|
|
readonly packageVersion?: string;
|
|
/** Measured, pruned installed bytes rounded up for honest UI estimates. */
|
|
readonly installSizeBytes: number;
|
|
}
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
/**
|
|
* CallDiff 0.4.1's supported extensions and pinned grammar packages.
|
|
*
|
|
* Size estimates describe Plannotator's pruned managed artifacts, not the
|
|
* much larger npm tarball expansion. They are deliberately rounded up and
|
|
* are re-measured whenever a grammar pin or pruning rule changes.
|
|
*/
|
|
export const CALL_FLOW_LANGUAGES = [
|
|
{ id: "javascript-typescript", label: "JavaScript and TypeScript", extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], kind: "core", installSizeBytes: 5 * MB },
|
|
{ id: "python", label: "Python", extensions: [".py"], kind: "pack", packageName: "tree-sitter-python", packageVersion: "0.25.0", installSizeBytes: 1 * MB },
|
|
{ id: "go", label: "Go", extensions: [".go"], kind: "pack", packageName: "tree-sitter-go", packageVersion: "0.25.0", installSizeBytes: 1 * MB },
|
|
{ id: "rust", label: "Rust", extensions: [".rs"], kind: "pack", packageName: "tree-sitter-rust", packageVersion: "0.24.0", installSizeBytes: 2 * MB },
|
|
{ id: "java", label: "Java", extensions: [".java"], kind: "pack", packageName: "tree-sitter-java", packageVersion: "0.23.5", installSizeBytes: 1 * MB },
|
|
{ id: "ruby", label: "Ruby", extensions: [".rb"], kind: "pack", packageName: "tree-sitter-ruby", packageVersion: "0.23.1", installSizeBytes: 3 * MB },
|
|
{ id: "c", label: "C", extensions: [".c", ".h"], kind: "pack", packageName: "tree-sitter-c", packageVersion: "0.24.1", installSizeBytes: 1 * MB },
|
|
{ id: "cpp", label: "C++", extensions: [".cc", ".cpp", ".cxx", ".hpp", ".hh"], kind: "pack", packageName: "tree-sitter-cpp", packageVersion: "0.23.4", installSizeBytes: 4 * MB },
|
|
{ id: "csharp", label: "C#", extensions: [".cs"], kind: "pack", packageName: "tree-sitter-c-sharp", packageVersion: "0.23.1", installSizeBytes: 6 * MB },
|
|
{ id: "php", label: "PHP", extensions: [".php"], kind: "pack", packageName: "tree-sitter-php", packageVersion: "0.24.2", installSizeBytes: 17 * MB },
|
|
{ id: "kotlin", label: "Kotlin", extensions: [".kt", ".kts"], kind: "pack", packageName: "tree-sitter-kotlin", packageVersion: "0.3.8", installSizeBytes: 4 * MB },
|
|
{ id: "swift", label: "Swift", extensions: [".swift"], kind: "pack", packageName: "tree-sitter-swift", packageVersion: "0.7.1", installSizeBytes: 4 * MB },
|
|
{ id: "scala", label: "Scala", extensions: [".scala"], kind: "pack", packageName: "tree-sitter-scala", packageVersion: "0.24.0", installSizeBytes: 4 * MB },
|
|
{ id: "lua", label: "Lua", extensions: [".lua"], kind: "pack", packageName: "@tree-sitter-grammars/tree-sitter-lua", packageVersion: "0.2.0", installSizeBytes: 1 * MB },
|
|
{ id: "elixir", label: "Elixir", extensions: [".ex", ".exs"], kind: "pack", packageName: "tree-sitter-elixir", packageVersion: "0.3.5", installSizeBytes: 2 * MB },
|
|
{ id: "bash", label: "Bash", extensions: [".sh", ".bash"], kind: "pack", packageName: "tree-sitter-bash", packageVersion: "0.25.1", installSizeBytes: 2 * MB },
|
|
{ id: "haskell", label: "Haskell", extensions: [".hs"], kind: "pack", packageName: "tree-sitter-haskell", packageVersion: "0.23.1", installSizeBytes: 4 * MB },
|
|
{ id: "zig", label: "Zig", extensions: [".zig"], kind: "pack", packageName: "@tree-sitter-grammars/tree-sitter-zig", packageVersion: "1.1.2", installSizeBytes: 1 * MB },
|
|
{ id: "solidity", label: "Solidity", extensions: [".sol"], kind: "pack", packageName: "tree-sitter-solidity", packageVersion: "1.2.13", installSizeBytes: 1 * MB },
|
|
{ id: "ocaml", label: "OCaml", extensions: [".ml"], kind: "pack", packageName: "tree-sitter-ocaml", packageVersion: "0.24.2", installSizeBytes: 15 * MB },
|
|
] as const satisfies readonly CallFlowLanguageDefinition[];
|
|
|
|
const LANGUAGE_BY_ID = new Map<CallFlowLanguageId, CallFlowLanguageDefinition>(
|
|
CALL_FLOW_LANGUAGES.map((language) => [language.id, language]),
|
|
);
|
|
const LANGUAGE_BY_EXTENSION = new Map<string, CallFlowLanguageDefinition>(
|
|
CALL_FLOW_LANGUAGES.flatMap((language) => language.extensions.map((extension) => [extension, language] as const)),
|
|
);
|
|
|
|
/** Return the pinned definition for a trusted language id. */
|
|
export function getCallFlowLanguage(id: CallFlowLanguageId): CallFlowLanguageDefinition {
|
|
const language = LANGUAGE_BY_ID.get(id);
|
|
if (!language) throw new Error(`Unknown CallDiff language: ${id}`);
|
|
return language;
|
|
}
|
|
|
|
/** Parse an untrusted language identifier at an HTTP/process boundary. */
|
|
export function parseCallFlowLanguageId(value: unknown): CallFlowLanguageId | null {
|
|
return typeof value === "string" && LANGUAGE_BY_ID.has(value as CallFlowLanguageId)
|
|
? value as CallFlowLanguageId
|
|
: null;
|
|
}
|
|
|
|
/** Resolve a repository-relative path to the language CallDiff would load. */
|
|
export function getCallFlowLanguageForPath(filePath: string): CallFlowLanguageDefinition | null {
|
|
const slash = filePath.lastIndexOf("/");
|
|
const dot = filePath.lastIndexOf(".");
|
|
if (dot <= slash) return null;
|
|
return LANGUAGE_BY_EXTENSION.get(filePath.slice(dot).toLowerCase()) ?? null;
|
|
}
|
|
|
|
/** Extract both sides of every patch file, including quoted and renamed paths. */
|
|
export function getCallFlowPatchFiles(rawPatch: string): string[] {
|
|
const files = new Set<string>();
|
|
for (const chunk of rawPatch.split(/(?=^diff --git )/m)) {
|
|
if (!chunk.startsWith("diff --git ")) continue;
|
|
const lines = chunk.split("\n");
|
|
const header = parseDiffGitHeader(lines[0]);
|
|
const fileLines = parseDiffFilePathLines(lines);
|
|
for (const filePath of [fileLines.oldPath ?? header.oldPath, fileLines.newPath ?? header.newPath]) {
|
|
if (filePath) files.add(filePath.replaceAll("\\", "/"));
|
|
}
|
|
}
|
|
return [...files];
|
|
}
|
|
|
|
export interface CallFlowPatchLanguageUsage {
|
|
readonly language: CallFlowLanguageDefinition;
|
|
readonly files: readonly string[];
|
|
}
|
|
|
|
/** Group supported changed paths by the grammar family they require. */
|
|
export function getCallFlowPatchLanguageUsage(rawPatch: string): CallFlowPatchLanguageUsage[] {
|
|
const filesByLanguage = new Map<CallFlowLanguageId, string[]>();
|
|
for (const filePath of getCallFlowPatchFiles(rawPatch)) {
|
|
const language = getCallFlowLanguageForPath(filePath);
|
|
if (!language) continue;
|
|
const files = filesByLanguage.get(language.id) ?? [];
|
|
files.push(filePath);
|
|
filesByLanguage.set(language.id, files);
|
|
}
|
|
return CALL_FLOW_LANGUAGES.flatMap((language) => {
|
|
const files = filesByLanguage.get(language.id);
|
|
return files ? [{ language, files }] : [];
|
|
});
|
|
}
|
|
|
|
/** Strict request body for POST /api/call-flow/install. */
|
|
export function parseCallFlowInstallRequest(value: unknown): { languageIds?: CallFlowLanguageId[] } | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (Object.keys(record).some((key) => key !== "languageIds")) return null;
|
|
if (record.languageIds === undefined) return {};
|
|
if (!Array.isArray(record.languageIds) || record.languageIds.length === 0 || record.languageIds.length > CALL_FLOW_LANGUAGES.length) return null;
|
|
const parsed = record.languageIds.map(parseCallFlowLanguageId);
|
|
if (parsed.some((id) => id === null)) return null;
|
|
return { languageIds: [...new Set(parsed as CallFlowLanguageId[])] };
|
|
}
|
|
|
|
/**
|
|
* Resolve an install POST without mixing the current review's default packs
|
|
* into an explicit Languages-list request. A missing core is the only target
|
|
* the server prepends to an explicit request.
|
|
*/
|
|
export function resolveCallFlowInstallTargets(
|
|
requestedIds: readonly CallFlowLanguageId[] | undefined,
|
|
defaultPlanIds: readonly CallFlowLanguageId[] | undefined,
|
|
runtimeAvailable: boolean,
|
|
): CallFlowLanguageId[] {
|
|
if (requestedIds === undefined) return [...(defaultPlanIds ?? [])];
|
|
const needsCore = !runtimeAvailable && defaultPlanIds?.includes(CALL_FLOW_CORE_LANGUAGE_ID);
|
|
return [...new Set(needsCore ? [CALL_FLOW_CORE_LANGUAGE_ID, ...requestedIds] : requestedIds)];
|
|
}
|