Skip to content

Published schema no longer describes the report the CLI writes — decide whether this package owns the contract or mirrors it #2

Description

@serge-ivo

Problem

@vibecodeqa/schema is described as the shared report contract — "a tiny leaf dependency" (README.md) that cli, app, mcp, action and github-app all build on. In practice the report contract now lives in cli/src/, and this package publishes a subset of it that no longer describes what the CLI writes.

Nobody notices, because parseReport is deliberately forward-compatible: every object is .passthrough() and CheckResult.details is z.record(z.unknown()) (src/report-schema.ts:22-28). Undocumented fields sail through validation and land in consumers as unknown. The cost shows up as casts and hand-copied interfaces in three repos.

Evidence

1. ToolRun — published shape is 7 fields, the CLI writes 13

// schema/src/types.ts:16-24
export interface ToolRun {
	tool: string; command: string; cwd: string;
	ok: boolean; durationMs: number; output: string; notFound: boolean;
}
// cli/src/runners/exec.ts:14-33 — the interface that actually produces the data
export interface ToolRun {
	tool: string; command: string; cwd: string;
	analyzerId?: string;
	analyzer?: string;
	projectId?: string;
	projectPath?: string;
	status: "success" | "failed";
	exitCode: number | null;
	ok: boolean; durationMs: number; output: string; notFound: boolean;
}

Six fields the schema does not have: analyzerId, analyzer, projectId, projectPath, status, exitCode. They arrived with vibecodeqa/cli#59 (closed 2026-08-07, "Package-tagged tool provenance"); the schema's ToolRun has not changed since 0.4.0 (01c757e, 2026-07-24).

Two structural details make this worse than a normal lag:

  • The CLI never imports the schema's ToolRun. It declares its own at cli/src/runners/exec.ts:14. So the published type is not a lagging copy that a build would catch — it is an unreferenced parallel declaration. There is no mechanism by which it could ever fail.
  • The schema's ToolRun is orphaned inside this package. ToolRun appears exactly once in src/, at its own declaration; no other type references it and report-schema.ts has no ToolRunSchema. parseReport() therefore never validates a tool run at all.

The consumer pays for it directly. The CLI attaches runs at cli/src/core.ts:264-266 (result.details = { ...result.details, toolRuns }), and because CheckResult has no toolRuns field, the app reads it through a cast:

// app/src/monitor/views/toolLog.logic.ts:141
const list = (c?.details as Record<string, unknown> | undefined)?.toolRuns;

2. WorkspaceInfo — extended locally in the CLI

// cli/src/types.ts:71-77
export interface WorkspaceInfo extends SchemaWorkspaceInfo {
	projects?: ProjectContext[];
	discovery?: { mode: "manifest" | "convention" | "single" | (string & {}); evidence: ProjectDiscoveryEvidence[] };
}

schema/src/types.ts:86-92 has neither. ProjectContext (cli/src/types.ts:50-69), ProjectDiscoveryEvidence, ProjectToolCommand and ProjectSupport exist only in the CLI.

3. Types that are hand-copied into a third repo

AnalyzerMetric / AnalyzerSnapshot are declared in cli/src/types.ts:13-30, emitted at report.meta.analyzerSnapshots[], absent from this package — and re-declared verbatim in the app:

// app/src/monitor/views/analyzerMetrics.logic.ts:34-50, under a header that says
// "The types the app consumes (mirroring the CLI contract exactly)."
export interface AnalyzerSnapshot { analyzerId: string; status: AnalyzerStatus; score?: number; ... }

Two hand-maintained copies of one contract, in repos that release independently.

4. Fields the CLI writes into every check, undeclared here

cli/src/core.ts:340 stamps status, scoreMode and scoreImpact onto every check's details, using CheckStatus and ScoreMode unions declared at cli/src/core.ts:72-75. Neither union exists in this package, and CheckResult (src/types.ts:26-33) has no status.

The CLI is already reaching for a schema field that was never declared:

// cli/src/core.ts:377
const declaredScoreMode = (meta as CheckMeta & { scoreMode?: ScoreMode }).scoreMode;

5. There is uncommitted schema work sitting in a stash

git stash list in this repo shows stash@{0}: On main: WIP app#27/schema half: ProjectContext + AnalyzerSnapshot schemas (backed up /tmp/vcqa-wip-backup/) [stashed by dev-agent 2026-08-08] — 149 insertions across src/types.ts, src/report-schema.ts, src/check-meta.ts, test/schema.test.ts. That work is not on main and not published. It should be recovered or deliberately discarded as part of whatever this issue decides; leaving it stashed is the worst of the three.

The decision

Is @vibecodeqa/schema the source of truth for the report contract, or a published mirror of it? Today it is neither on purpose, which is why it drifts.

Option A — schema owns the contract (recommended)

Rule: any type that appears in report.json is declared here first; cli and app import it and may not re-declare it. A CLI change that adds a report field is blocked on a schema release.

  • Ends the parallel declarations by making them impossible — the CLI's exec.ts would import ToolRun and a missing field becomes a type error at build time, not a silent divergence.
  • Matches what cli/CLAUDE.md:154 and :172 already assert for CHECK_META ("the schema package is the source of truth"). Extends an existing, accepted rule rather than inventing one.
  • Cost: two-step releases. Adding a report field means schema release → consumer bump → CLI release. That is real friction on a repo that releases via CI-only OIDC publishing.
  • Mitigation for the friction: batch. Nothing here needs to be per-field.

Option B — CLI owns the contract, schema is a generated/checked mirror

Keep declaring types in cli/src/, and add a CI check in this repo (or the CLI's) that fails when the published schema and the CLI's types diverge — e.g. a fixture-based conformance test in the spirit of mcp/test/report-compat.test.mjs.

  • Zero friction for CLI development; drift becomes loud instead of silent.
  • Cost: this package stops being a contract and becomes documentation. Consumers still get types late, and app still has to choose between waiting and copying. Does not fix the app-side copy of AnalyzerSnapshot.

Option C — status quo

Local extension in the CLI, schema bumped ad hoc when someone notices.

  • The four releases since 0.4.0 are the evidence for what this produces: an orphaned ToolRun, a stashed branch of unlanded work, a hand-copied AnalyzerSnapshot, and two open consumer issues asking for the same interface.

Recommendation

Option A for anything that crosses a package boundary in report.json (ToolRun, CheckResult, WorkspaceInfo, ProjectContext, AnalyzerSnapshot, StackInfo, the CheckStatus/ScoreMode unions), Option C explicitly for CLI-internal types (ToolRunContext, ToolRunFilter, NormalizedCheckResult — these never appear in a report and should not be published). Write that boundary into this repo's README so the next contributor does not have to infer it.

Then land the backlog as one 0.5.0, not three:

  1. ToolRun gains the six fields the CLI already writes (analyzerId, analyzer, projectId, projectPath, status, exitCode).
  2. ToolRun gains an origin/source discriminator — vibecodeqa/app#52.
  3. ToolRun gains truncated, stderr, startedAt — Capture the complete, verifiable log for EVERY delegated tool (trust: prove knip/eslint/etc. actually ran) cli#26.
  4. CheckResult gains an optional toolRuns?: ToolRun[], or the contract states in prose that details.toolRuns is the canonical location (see open question).
  5. WorkspaceInfo.projects / .discovery, ProjectContext, AnalyzerSnapshot, AnalyzerMetric move here — starting from stash@{0}, which already drafted most of this.
  6. A ToolRunSchema in report-schema.ts so parseReport actually validates a tool run.

Landing app#52 and cli#26 separately means two schema releases and two rounds of consumer bumps for edits to the same interface.

Consequence of not deciding

app#52 is labelled blocked in a repo that cannot unblock it. cli#26's acceptance criteria include emitting truncated and stderr, which the CLI can do unilaterally — and it will, into an undocumented details blob, exactly as analyzerId and exitCode went in. Each iteration adds another cast in app and another mirrored interface. mcp continues to pin a schema that validates a report shape which does not describe the fields its own tools read.

Acceptance criteria

  • This repo's README states which types are owned here and which are CLI-internal, in one short section.
  • ToolRun in src/types.ts is field-for-field a superset of what cli/src/runners/exec.ts writes, and cli/src/runners/exec.ts imports it instead of declaring its own (CLI-side follow-up, but the schema half must land first).
  • parseReport() validates toolRuns entries rather than passing them through as unknown.
  • app/src/monitor/views/analyzerMetrics.logic.ts can delete its mirrored AnalyzerMetric/AnalyzerSnapshot declarations and import them (app-side follow-up).
  • stash@{0} is either landed or dropped, and the issue says which.
  • One release covers app#52 and cli#26, not two.

Constraints the implementer would otherwise miss

  • Releases are CI-only via npm OIDC trusted publishing and require node 24 / npm >= 11. Never npm publish locally.
  • Every consumer pins independently — mcp 0.4.2, app ^0.4.1, cli ^0.4.2. A schema release changes nothing for a user until each consumer bumps and releases. Sequence the bumps.
  • .passthrough() everywhere is a deliberate design decision (README.md, "parseReport() keeps the report shape strict where the vocabulary is truly closed... and forward-compatible where producers evolve"). Adding fields must not turn into tightening validation on old reports — mcp parses a committed fixture from a pinned older CLI (mcp/test/report-compat.test.mjs) and that must keep passing.
  • cli/src/core.ts:75 already defines ScoreMode; if it is published here, the union must match exactly or the cast at cli/src/core.ts:377 cannot be removed cleanly.
  • Related to dead-code is emitted by the CLI but documented by no schema version — every explain surface returns "Unknown check" #1 (dead-code metadata), which also proposes a CheckMeta.scoreMode. If both land, land them in the same release.

Open questions for the maintainer

  1. Canonical location for tool-run provenance. app#52 says the "contract's top-level check.toolRuns" is empty while analyzers write details.toolRuns. Verified: there is no top-level toolRuns — CheckResult in src/types.ts:26-33 has no such field and cli/src/core.ts:264-266 only ever writes details.toolRuns. So the choice is (a) promote it to a declared CheckResult.toolRuns? and migrate producers, or (b) declare details.toolRuns canonical and document it. (b) is cheaper and breaks nothing; (a) is cleaner and is a breaking change for every consumer reading the nested path. This is a maintainer call, not an implementer one.
  2. analyzer vs analyzerId. cli/src/runners/exec.ts:21-22 carries both, with cli/src/runners/exec.ts:87 normalising analyzerId ?? analyzer. Publish both, or publish only analyzerId and treat analyzer as a deprecated CLI-internal alias?

Recommendation on vibecodeqa/app#52

Move it here. It asks for a field on a type this package owns, it is labelled blocked in a repo that has no ability to unblock it, and it is currently the only tracking anywhere for that schema change. Suggested handling:

  • gh issue transfer 52 vibecodeqa/schema, then rescope its body to the schema half (the ToolRun origin/source discriminator), and fold its open provenance-location question into open question 1 above.
  • File a small app-side follow-up for the part that genuinely belongs to app: ToolLogModal's empty state hardcodes "These findings come from the built-in analyzer", which becomes false the moment an external analyzer runs. That is a copy change in app, independent of the schema.

Leaving it in app is the alternative, and the reason to reject it is that it hides a schema dependency from the repo that owns it — this repo had zero issues while two other repos tracked work that only it can do.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

analyzer-platformAnalyzer engine, registry, contracts, and normalized resultsenhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions