diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c154c3..eaa77dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ One short entry per release, written for users deciding whether to upgrade. +## [8.2.1] - 2026-09-05 + +Validation and publication claims now reject three misleading success paths. + +- Named JUnit evidence uses strict XML parsing. Truncated or malformed documents, + comments, CDATA, and log markup cannot invent passing cases; DTDs are refused. + Standard XML quoting and character references are supported, and deeply nested + reports no longer repeatedly scan their ancestry for each case. +- Source fingerprints distinguish literal untracked filenames from Git index + records, including names that resemble gitlinks. Non-UTF-8 filenames fail closed + rather than being decoded into a different path. +- Preparing an already-published GitHub release requires every expected asset to + have exact size and digest. Missing or pending assets no longer report success; + explicit publish recovery remains available without overwriting existing bytes. +- Lock-recovery documentation now describes the shipped local-process behavior, + and the Biome schema matches the pinned tool version. +- The development lockfile updates the SDK's transitive `toml` dependency to + `4.3.0`, clearing high-severity parser advisories without changing the host pin. +- **Session v5 schema:** unchanged. Public tools, commands, guides, and agents + retain their existing names and inputs. + +Install or update: + +```bash +opencode plugin opencode-plugin-flow@8.2.1 --global --force +``` + ## [8.2.0] - 2026-08-31 Reviewer selection is now native plugin configuration, and Flow reports enough diff --git a/README.md b/README.md index 07c0074e..5dda34b7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ expensive, and it is overhead when it is not. Install the exact npm release through OpenCode: ```bash -opencode plugin opencode-plugin-flow@8.2.0 --global --force +opencode plugin opencode-plugin-flow@8.2.1 --global --force ``` Omit `--global` for project scope. Version pins are exact and never update on @@ -51,7 +51,7 @@ The equivalent manual project configuration is: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-plugin-flow@8.2.0"] + "plugin": ["opencode-plugin-flow@8.2.1"] } ``` @@ -62,7 +62,7 @@ For an explicit reviewer model, use OpenCode's plugin tuple options: "$schema": "https://opencode.ai/config.json", "plugin": [ [ - "opencode-plugin-flow@8.2.0", + "opencode-plugin-flow@8.2.1", { "reviewer": { "model": "provider/model", "steps": 80 } } ] ] diff --git a/biome.json b/biome.json index 57a83315..10e00c6d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", "files": { "includes": [ "**", diff --git a/bun.lock b/bun.lock index 0f7e2a79..92619bde 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "opencode-plugin-flow", "dependencies": { + "saxes": "6.0.0", "zod": "4.4.3", }, "devDependencies": { @@ -137,11 +138,13 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -151,6 +154,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/docs/adr/0012-named-results-over-exit-codes.md b/docs/adr/0012-named-results-over-exit-codes.md index dcc1c525..4a2b54ec 100644 --- a/docs/adr/0012-named-results-over-exit-codes.md +++ b/docs/adr/0012-named-results-over-exit-codes.md @@ -66,6 +66,11 @@ The bounded JUnit reader extracts only the attributes needed for declared names. Common test runners can produce the format without Flow learning runner-specific output text. +Amended 2026-09-05: strict XML parsing replaces regex extraction, which accepted +cases inside comments, CDATA, and truncated documents. Only cases within suite +structure count; any parse error or DTD discards the report. The parser lives in +infrastructure and retains only declared names, with no new state fields. + Gate and extra evidence use the same record. A whole-suite exit claim uses `assertions: []`. A gate may also bind an exact acceptance case when that case is part of the whole-suite command; the command still remains the canonical broad gate. diff --git a/docs/development.md b/docs/development.md index 43b16a8c..9d135819 100644 --- a/docs/development.md +++ b/docs/development.md @@ -165,3 +165,7 @@ the same ref proof. That draft is the recovery marker if npm succeeds and `main` then advances. Finalization rechecks the remote tag, refuses conflicting metadata or assets, and publishes only after every asset digest matches. Reruns converge after partial success without replacing published bytes. + +Preparing an already-published release is read-only and requires exact assets. +Missing or pending assets fail preparation; use the explicit `github-publish` +recovery path with the original inputs and tag proof to restore a missing asset. diff --git a/docs/maintainer-contract.md b/docs/maintainer-contract.md index 65bce5f0..de1f514b 100644 --- a/docs/maintainer-contract.md +++ b/docs/maintainer-contract.md @@ -248,8 +248,10 @@ manager contract. chain. - Source identity requires a readable Git worktree and rejects tracked submodules explicitly. -- A timed-out project lock fails closed. Automatic stale-lock stealing is not - allowed because ownership cannot be reclaimed without a race. +- Lock timeout fails closed. Reclamation requires an absent local PID, an + exclusive claim, and a matching owner token. Live or unreadable owners are + untouched; age grants no ownership. Shared-host or cross-PID-namespace + workspaces are unsupported. ## OpenCode surface diff --git a/evals/reviewer-assignment.ts b/evals/reviewer-assignment.ts index f293f68f..4a442f43 100644 --- a/evals/reviewer-assignment.ts +++ b/evals/reviewer-assignment.ts @@ -1,5 +1,4 @@ import type { ReviewFinding, Session } from "../src/domain/session.js"; -import { observeAssertions } from "../src/domain/test-results.js"; import { normalizeEvidencePlatform } from "../src/domain/validation.js"; import { createFileSessionRepository } from "../src/infrastructure/fs/session-repository.js"; import { createWorkspaceFlowService } from "../src/infrastructure/fs/workspace-flow-service.js"; @@ -8,6 +7,7 @@ import { prepareWorkspaceValidation, readWorkspaceTestReport, } from "../src/infrastructure/fs/workspace-validation.js"; +import { observeAssertions } from "../src/infrastructure/junit-results.js"; import { canonicalSha256 } from "./canonical-json.js"; import { assertReviewerCaseTruth, diff --git a/package.json b/package.json index aa1847de..0edeef68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-flow", - "version": "8.2.0", + "version": "8.2.1", "description": "Small durable planning, validation, and review workflow for OpenCode", "type": "module", "repository": { @@ -64,6 +64,7 @@ "@opencode-ai/plugin": ">=1.18.3 <2" }, "dependencies": { + "saxes": "6.0.0", "zod": "4.4.3" }, "devDependencies": { diff --git a/scripts/release-publish.ts b/scripts/release-publish.ts index 9d26d974..5f8de46d 100644 --- a/scripts/release-publish.ts +++ b/scripts/release-publish.ts @@ -635,7 +635,13 @@ export async function convergeGithubRelease( assertReleaseIdentity(release, input); assertNoUnexpectedAssets(release, assets); if (input.mode === "prepare" && !release.draft) { - for (const asset of assets) assetIssue(release, asset); + for (const asset of assets) { + if (assetIssue(release, asset) !== null) { + throw new Error( + `Published GitHub release asset ${asset.name} is not exact.`, + ); + } + } return { state: "prepared", releaseId: release.id }; } for (const asset of assets) { diff --git a/src/domain/test-results.ts b/src/domain/test-results.ts index 5c4d02be..ce6250a9 100644 --- a/src/domain/test-results.ts +++ b/src/domain/test-results.ts @@ -1,119 +1,5 @@ -import { MAX_DECLARED_ASSERTIONS } from "./limits.js"; import type { ObservedAssertion } from "./session.js"; -/** - * Named test outcomes, read from a JUnit report the command wrote. - * - * An exit code says a process succeeded, never which cases ran, and `test.skip` exits - * zero. So a declared name is satisfied only by a case a report says passed: skipped - * is `skipped`, unmentioned is `absent`, and neither discharges anything - * (`docs/adr/0012-named-results-over-exit-codes.md`). - * - * Regex over XML, which is normally the wrong tool. It is right here: the shape is one - * bounded, well-specified element, and a parser dependency to read four attributes is - * a worse trade than a pattern with a test suite pinning it. - */ -const TESTCASE = /]*?)(\/>|>([\s\S]*?)<\/testcase\s*>)/g; -const ATTRIBUTE = /([\w:.-]+)\s*=\s*"([^"]*)"/g; -const NEGATIVE = /<(failure|error|skipped)\b/; - -function attributes(source: string): Record { - const found: Record = {}; - for (const match of source.matchAll(ATTRIBUTE)) { - if (match[1]) found[match[1]] = decodeEntities(match[2] ?? ""); - } - return found; -} - -/** The five predefined XML entities, which is all a JUnit writer emits. */ -function decodeEntities(value: string): string { - return value - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - -/** - * Every label a declared assertion may name this case by. - * - * Runners disagree about where the suite name goes, so the bare name and the three - * common joins are all accepted rather than guessing at one runner's shape. - * - * `file` is read as a suite too, because Bun — the runner Flow itself is built on — - * emits `classname=""` and puts the path in `file`. Measured against real - * `bun test --reporter=junit` output: without this, the bare name matched and - * `src/platform.test.ts > creates the replacement on Windows` read `absent`, which is - * a refusal of a case that passed. The most natural way to write a qualified name was - * the one way it did not work. - */ -function labels(attributes: Record): string[] { - const name = attributes.name ?? ""; - if (name === "") return []; - const suites = [ - attributes.classname, - attributes.class, - attributes.file, - attributes.filepath, - ].filter((suite): suite is string => suite !== undefined && suite !== ""); - return [ - name, - ...new Set( - suites.flatMap((suite) => [ - `${suite} ${name}`, - `${suite}.${name}`, - `${suite} > ${name}`, - ]), - ), - ]; -} - -/** Every declared name, unobserved. The shape of every fail-closed route. */ -function absent(declared: readonly string[]): ObservedAssertion[] { - return declared - .slice(0, MAX_DECLARED_ASSERTIONS) - .map((name) => ({ name, status: "absent" as const })); -} - -/** - * Outcomes for the declared names, and nothing else: a full case inventory would put - * an unbounded copy of someone's suite into durable state to answer a question about - * a handful of names. - */ -export function observeAssertions( - declared: readonly string[], - report: string, -): ObservedAssertion[] { - const outcomes = new Map(); - // A bare `` is not a report. Without this, any text containing one -- - // a truncated write, a log that quoted a case name, a file that is not JUnit at - // all -- could name a declared case `passed`, and this rule exists to fail closed. - // Every JUnit writer wraps cases in a suite, so requiring the wrapper costs - // nothing real and makes a fragment read `absent` instead of satisfying anything. - if (!/ ({ - name, - status: outcomes.get(name) ?? ("absent" as const), - })); -} - /** Whether every declared name was reported as a passing case. */ export function assertionsSatisfied( declared: readonly string[], diff --git a/src/infrastructure/fs/source-identity.ts b/src/infrastructure/fs/source-identity.ts index f51d5364..59993d2c 100644 --- a/src/infrastructure/fs/source-identity.ts +++ b/src/infrastructure/fs/source-identity.ts @@ -21,8 +21,11 @@ function fail(message: string, cause?: unknown): never { } function parseGitWorkspaceEntry(entry: string): string { - const staged = /^([0-7]{6}) [0-9a-f]+ [0-3]\t([\s\S]+)$/.exec(entry); - if (!staged) return entry; + // -t distinguishes raw untracked names from index records without guessing + // from filename bytes, which may themselves look exactly like a staged entry. + if (entry.startsWith("? ")) return entry.slice(2); + const staged = /^[HSM] ([0-7]{6}) [0-9a-f]+ [0-3]\t([\s\S]+)$/.exec(entry); + if (!staged) fail("Git returned an unrecognized workspace record."); if (staged[1] === "160000") { fail( "Flow does not support Git submodules in source fingerprints; remove the tracked gitlink before continuing.", @@ -42,6 +45,7 @@ function gitWorkspacePaths(workspace: string): Promise { "-co", "--exclude-standard", "--stage", + "-t", "-z", ], { encoding: "buffer", maxBuffer: 32 * 1024 * 1024 }, @@ -55,10 +59,13 @@ function gitWorkspacePaths(workspace: string): Promise { ); return; } - const text = Buffer.isBuffer(stdout) - ? stdout.toString("utf8") - : String(stdout); try { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(stdout); + } catch (error) { + fail("Flow requires UTF-8 workspace filenames.", error); + } resolvePaths( text.split("\0").filter(Boolean).map(parseGitWorkspaceEntry), ); diff --git a/src/infrastructure/junit-results.ts b/src/infrastructure/junit-results.ts new file mode 100644 index 00000000..6dab0c81 --- /dev/null +++ b/src/infrastructure/junit-results.ts @@ -0,0 +1,104 @@ +import { SaxesParser } from "saxes"; +import { + MAX_DECLARED_ASSERTIONS, + MAX_TEST_REPORT_BYTES, +} from "../domain/limits.js"; +import type { ObservedAssertion } from "../domain/session.js"; + +function labels(attributes: Record): string[] { + const name = attributes.name ?? ""; + if (name === "") return []; + const suites = [ + attributes.classname, + attributes.class, + attributes.file, + attributes.filepath, + ].filter((suite): suite is string => suite !== undefined && suite !== ""); + return [ + name, + ...suites.flatMap((suite) => [ + `${suite} ${name}`, + `${suite}.${name}`, + `${suite} > ${name}`, + ]), + ]; +} + +/** Read only declared cases from a complete JUnit document, never log text. */ +export function observeAssertions( + declared: readonly string[], + report: string, +): ObservedAssertion[] { + const names = declared.slice(0, MAX_DECLARED_ASSERTIONS); + const absent = (): ObservedAssertion[] => + names.map((name) => ({ name, status: "absent" })); + if (names.length === 0 || Buffer.byteLength(report) > MAX_TEST_REPORT_BYTES) + return absent(); + const wanted = new Set(names); + const outcomes = new Map(); + const stack: string[] = []; + let nonSuiteAncestors = 0; + let current: + | { + names: string[]; + depth: number; + status: "passed" | "skipped" | "failed"; + } + | undefined; + const parser = new SaxesParser(); + parser.on("doctype", () => { + throw new Error("JUnit evidence must not contain a DTD."); + }); + parser.on("opentag", (tag) => { + if ( + stack.length === 0 && + tag.name !== "testsuite" && + tag.name !== "testsuites" + ) + throw new Error("JUnit evidence requires a suite root."); + if ( + tag.name === "testcase" && + stack.length > 0 && + nonSuiteAncestors === 0 + ) { + current = { + names: labels(tag.attributes).filter((name) => wanted.has(name)), + depth: stack.length, + status: "passed", + }; + } else if (current && stack.length === current.depth + 1) { + if (tag.name === "failure" || tag.name === "error") + current.status = "failed"; + else if (tag.name === "skipped" && current.status !== "failed") + current.status = "skipped"; + } + stack.push(tag.name); + if (tag.name !== "testsuite" && tag.name !== "testsuites") + nonSuiteAncestors += 1; + }); + parser.on("closetag", () => { + const name = stack.pop(); + if (name !== "testsuite" && name !== "testsuites") nonSuiteAncestors -= 1; + if (!current || stack.length !== current.depth) return; + for (const name of current.names) { + const prior = outcomes.get(name); + if ( + prior === undefined || + prior === "passed" || + current.status === "failed" + ) + outcomes.set(name, current.status); + } + current = undefined; + }); + try { + // The parser throws on malformed XML. Discard even cases read before the error. + parser.write(report).close(); + } catch { + return absent(); + } + return names.map((name) => ({ + name, + status: outcomes.get(name) ?? "absent", + })); +} diff --git a/src/platform/opencode/validation-capture.ts b/src/platform/opencode/validation-capture.ts index 1110023c..7c7b49bb 100644 --- a/src/platform/opencode/validation-capture.ts +++ b/src/platform/opencode/validation-capture.ts @@ -8,8 +8,8 @@ import type { ValidationIneligibleReason, ValidationObservation, } from "../../domain/session.js"; -import { observeAssertions } from "../../domain/test-results.js"; import { isValidationEligible } from "../../domain/validation.js"; +import { observeAssertions } from "../../infrastructure/junit-results.js"; import type { Hooks } from "./sdk.js"; const MAX_CAPTURES = 128; diff --git a/tests/release-publish.test.ts b/tests/release-publish.test.ts index ab3e3188..97389af8 100644 --- a/tests/release-publish.test.ts +++ b/tests/release-publish.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { type CommandResult, convergeGithubRelease, @@ -209,6 +209,76 @@ describe("npm publication reconciliation", () => { }); describe("GitHub release reconciliation", () => { + test.each(["missing", "pending", "conflicting", "wrong-size", "exact"])( + "preparing a published release with %s assets stays read-only", + async (state) => { + const fixture = await artifactFixture(); + try { + const input = { + repository: "example/repo", + token: "test-token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes", + assets: [fixture.artifactPath], + mode: "prepare" as const, + }; + let mutations = 0; + const runtime: PublicationRuntime = { + fetch: async (_url, init) => { + if ((init?.method ?? "GET") !== "GET") mutations += 1; + return jsonResponse(200, [ + { + id: 8, + tag_name: input.tag, + name: input.tag, + body: input.notes, + target_commitish: COMMIT, + draft: false, + prerelease: false, + assets: + state === "missing" + ? [] + : [ + { + name: basename(fixture.artifactPath), + size: + state === "wrong-size" + ? 0 + : Buffer.byteLength("exact package bytes"), + digest: + state === "pending" + ? null + : state === "conflicting" + ? "sha256:wrong" + : `sha256:${fixture.sha256}`, + }, + ], + }, + ]); + }, + run: async () => { + throw new Error("Unexpected command"); + }, + sleep: async () => {}, + }; + const prepared = convergeGithubRelease(input, runtime); + if (state === "exact") + await expect(prepared).resolves.toEqual({ + state: "prepared", + releaseId: 8, + }); + else await expect(prepared).rejects.toThrow(/not exact|conflicting/); + expect(mutations).toBe(0); + } finally { + await rm(dirname(fixture.artifactPath), { + recursive: true, + force: true, + }); + } + }, + ); + test("reconciles an ambiguous draft creation before publishing", async () => { let release: | { @@ -537,7 +607,7 @@ describe("GitHub release reconciliation", () => { expect(mutations).toBe(0); }); - test("recovers a published release that is missing an exact asset", async () => { + test("refuses incomplete preparation but permits explicit published-asset recovery", async () => { const fixture = await artifactFixture(); let release = { id: 9, @@ -578,8 +648,8 @@ describe("GitHub release reconciliation", () => { run: async () => result(0), sleep: async () => {}, }; - expect( - await convergeGithubRelease( + await expect( + convergeGithubRelease( { repository: "owner/repo", token: "token", @@ -594,7 +664,9 @@ describe("GitHub release reconciliation", () => { }, runtime, ), - ).toEqual({ state: "prepared", releaseId: 9 }); + ).rejects.toThrow( + "Published GitHub release asset package.tgz is not exact", + ); expect(mutations).toBe(0); const npmRuntime: PublicationRuntime = { fetch: async () => diff --git a/tests/source-identity.test.ts b/tests/source-identity.test.ts index 0173f4f5..64bd66c4 100644 --- a/tests/source-identity.test.ts +++ b/tests/source-identity.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { execFile } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { mkdir, mkdtemp, @@ -85,6 +85,98 @@ describe("workspace content fingerprint", () => { expect(await digest(root)).not.toBe(trackedChange); }); + test.skipIf(process.platform === "win32")( + "hashes index-shaped untracked filenames literally, including fake gitlinks", + async () => { + const root = await repository(); + for (const mode of ["100644", "160000"]) { + const name = `${mode} ${"a".repeat(40)} 0\tdecoy.txt`; + await writeFile(join(root, name), "one\n"); + const before = await digest(root); + await writeFile(join(root, name), "two\n"); + expect(await digest(root)).not.toBe(before); + const untracked = await digest(root); + await git(root, "add", "--", name); + expect(await digest(root)).toBe(untracked); + } + }, + ); + + test("preserves Unicode and whitespace paths before and after staging", async () => { + const root = await repository(); + const names = ["space name.txt", "caf\u00e9.txt"]; + if (process.platform !== "win32") names.push("tab\tand\nnewline.txt"); + for (const name of names) { + await writeFile(join(root, name), "one\n"); + const before = await digest(root); + await writeFile(join(root, name), "two\n"); + expect(await digest(root)).not.toBe(before); + const untracked = await digest(root); + await git(root, "add", "--", name); + expect(await digest(root)).toBe(untracked); + } + }); + + test.each(["skip-worktree", "unmerged"])( + "fingerprints effective content independently of %s index records", + async (state) => { + const root = await repository(); + await writeFile(join(root, "source.txt"), "one\n"); + await git(root, "add", "source.txt"); + const before = await digest(root); + if (state === "skip-worktree") { + await git(root, "update-index", "--skip-worktree", "source.txt"); + } else { + const hash = (await git(root, "ls-files", "--stage")).split(" ")[1]; + const indexed = spawnSync( + "git", + ["-C", root, "update-index", "--index-info"], + { + input: `0 ${"0".repeat(40)}\tsource.txt\n${[1, 2, 3].map((stage) => `100644 ${hash} ${stage}\tsource.txt\n`).join("")}`, + }, + ); + expect(indexed.status).toBe(0); + } + const records = ( + await git( + root, + "ls-files", + "-co", + "--exclude-standard", + "--stage", + "-t", + "-z", + ) + ) + .split("\0") + .filter(Boolean); + expect(records).toHaveLength(state === "skip-worktree" ? 1 : 3); + expect( + records.every((record) => + record.startsWith(state === "skip-worktree" ? "S " : "M "), + ), + ).toBe(true); + expect(await digest(root)).toBe(before); + await writeFile(join(root, "source.txt"), "two\n"); + expect(await digest(root)).not.toBe(before); + }, + ); + + test("rejects non-UTF-8 index paths instead of hashing a missing replacement path", async () => { + const root = await repository(); + const record = Buffer.concat([ + Buffer.from(`100644 ${"a".repeat(40)} 0\tinvalid-`), + Buffer.from([0xff, 0]), + ]); + const indexed = spawnSync( + "git", + ["-C", root, "update-index", "-z", "--index-info"], + { input: record }, + ); + expect(indexed.status).toBe(0); + await expect(digest(root)).rejects.toThrow("UTF-8 workspace filenames"); + }); + test("represents a missing tracked path and returns to the same digest when restored", async () => { const root = await repository(); const source = join(root, "tracked.txt"); diff --git a/tests/test-results.test.ts b/tests/test-results.test.ts index 7293bda2..74836a53 100644 --- a/tests/test-results.test.ts +++ b/tests/test-results.test.ts @@ -21,10 +21,10 @@ import { join } from "node:path"; import { MAX_TEST_REPORT_BYTES } from "../src/domain/limits.js"; import { assertionsSatisfied, - observeAssertions, unmetAssertions, } from "../src/domain/test-results.js"; import { readWorkspaceTestReport } from "../src/infrastructure/fs/workspace-validation.js"; +import { observeAssertions } from "../src/infrastructure/junit-results.js"; /** One bun-shaped report: a pass, a skip, a failure, and a self-closing pass. */ const REPORT = ` @@ -164,6 +164,68 @@ describe("observing named test results", () => { ); }); + test.each([ + '', + '', + ']]>', + '', + '', + '', + '', + '', + '', + '', + ']>', + '', + ])("does not invent passing evidence from %s", (report) => { + expect(observeAssertions(["never-ran"], report)).toEqual([ + { name: "never-ran", status: "absent" }, + ]); + }); + + test("accepts XML quoting and character references without reading log markup", () => { + const report = ` + ]]> + `; + expect(observeAssertions(['a > b & "c"'], report)).toEqual([ + { name: 'a > b & "c"', status: "passed" }, + ]); + }); + + test("handles deep suites with many cases within the report limit", () => { + const report = + "".repeat(100_000) + + ''.repeat(80_000) + + "".repeat(100_000); + expect(Buffer.byteLength(report)).toBeLessThan(MAX_TEST_REPORT_BYTES); + expect(observeAssertions(["x"], report)).toEqual([ + { name: "x", status: "passed" }, + ]); + }); + + test("restores suite eligibility after leaving nested log elements", () => { + const report = ` + + + `; + expect(observeAssertions(["fake", "real", "next"], report)).toEqual([ + { name: "fake", status: "absent" }, + { name: "real", status: "skipped" }, + { name: "next", status: "passed" }, + ]); + }); + + test.each([ + ["", "", ""], + ["", "", ""], + ["", "", ""], + ])("keeps failed duplicates regardless of ordering: %j", (...bodies) => { + const report = `${bodies.map((body) => `${body}`).join("")}`; + expect(observeAssertions(["flaky"], report)).toEqual([ + { name: "flaky", status: "failed" }, + ]); + }); + test("names the unmet cases with why, so a refusal can quote them", () => { const observed = observeAssertions( ["creates the replacement on Windows", "never written"],