Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
}
```

Expand All @@ -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 } }
]
]
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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": [
"**",
Expand Down
7 changes: 6 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/adr/0012-named-results-over-exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions docs/maintainer-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion evals/reviewer-assignment.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -64,6 +64,7 @@
"@opencode-ai/plugin": ">=1.18.3 <2"
},
"dependencies": {
"saxes": "6.0.0",
"zod": "4.4.3"
},
"devDependencies": {
Expand Down
8 changes: 7 additions & 1 deletion scripts/release-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
114 changes: 0 additions & 114 deletions src/domain/test-results.ts
Original file line number Diff line number Diff line change
@@ -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 = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase\s*>)/g;
const ATTRIBUTE = /([\w:.-]+)\s*=\s*"([^"]*)"/g;
const NEGATIVE = /<(failure|error|skipped)\b/;

function attributes(source: string): Record<string, string> {
const found: Record<string, string> = {};
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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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, string>): 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<string, "passed" | "failed" | "skipped">();
// A bare `<testcase>` 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 (!/<testsuites?\b/.test(report)) return absent(declared);
for (const match of report.matchAll(TESTCASE)) {
const body = match[3] ?? "";
const status = NEGATIVE.test(body)
? /<skipped\b/.test(body)
? ("skipped" as const)
: ("failed" as const)
: ("passed" as const);
for (const label of labels(attributes(match[1] ?? ""))) {
// First writing wins, so a name reported twice cannot be upgraded to passed
// by a later duplicate: a retried case that failed once stays failed.
const prior = outcomes.get(label);
if (prior === undefined || (prior === "passed" && status !== "passed")) {
outcomes.set(label, status);
}
}
}
return declared.slice(0, MAX_DECLARED_ASSERTIONS).map((name) => ({
name,
status: outcomes.get(name) ?? ("absent" as const),
}));
}

/** Whether every declared name was reported as a passing case. */
export function assertionsSatisfied(
declared: readonly string[],
Expand Down
17 changes: 12 additions & 5 deletions src/infrastructure/fs/source-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -42,6 +45,7 @@ function gitWorkspacePaths(workspace: string): Promise<string[]> {
"-co",
"--exclude-standard",
"--stage",
"-t",
"-z",
],
{ encoding: "buffer", maxBuffer: 32 * 1024 * 1024 },
Expand All @@ -55,10 +59,13 @@ function gitWorkspacePaths(workspace: string): Promise<string[]> {
);
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),
);
Expand Down
Loading