codex-plugin-doctor 1.47.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,8 @@ codex-plugin-doctor doctor contract --json --output output-contract.json
197
197
  codex-plugin-doctor doctor corpus
198
198
  codex-plugin-doctor doctor corpus --json --output validation-corpus.json
199
199
  codex-plugin-doctor doctor corpus --manifest ../private-corpus/corpus.json --json
200
+ codex-plugin-doctor doctor corpus metrics --manifest ../private-corpus/metrics.json --markdown
201
+ codex-plugin-doctor doctor corpus metrics --manifest ../private-corpus/metrics.json --json --min-precision 0.90 --min-recall 0.85 --max-false-positive-rate 0.10
200
202
  codex-plugin-doctor doctor npm <published-plugin-package>
201
203
  codex-plugin-doctor doctor npm <published-plugin-package> --json --output npm-preinstall.json
202
204
  codex-plugin-doctor doctor attest .
@@ -315,6 +317,10 @@ Docker runtime mode supports local Node.js stdio MCP servers. It uses a digest-p
315
317
 
316
318
  External corpus evaluation is static and offline. `doctor corpus --manifest <corpus.json>` validates anonymized local snapshots using stable content digests and exact finding fingerprints without downloading dependencies or starting target servers. Reports omit manifest and package paths and fail on digest drift, missing expected findings, present disputed findings, or unreviewed findings. See [Real-World Validation](./docs/guides/real-world-validation.md).
317
319
 
320
+ `doctor corpus metrics --manifest <corpus.json>` measures reviewed true positives, false positives, and false negatives as precision, recall, and emitted-finding false-positive share. Optional thresholds make the measurement release-gating; incomplete reviews and digest drift return exit `2` instead of producing a favorable score. The dedicated metrics manifest stays offline, requires contained snapshot paths, and may record immutable HTTPS Git provenance without fetching it.
321
+
322
+ `doctor corpus metrics diff --before <metrics.json> --after <metrics.json>` compares two complete metrics reports from the same corpus identity. Add `--fail-on-regression` to fail CI when precision or recall decreases, or when the reviewed emitted-finding false-positive share increases. Reports with different `corpusDigest` values are rejected as non-comparable, and diff output never includes the input report paths.
323
+
318
324
  `self-test` runs the bundled runtime-complete sample through static validation, runtime MCP probes, and the compatibility scorecard. It is the fastest post-install check after `npm install -g codex-plugin-doctor`.
319
325
 
320
326
  `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup. `doctor contract` publishes the machine-readable output contract, including public JSON schema surfaces, stable-through-1.0 compatibility metadata, and a frozen rule catalog digest. Add `--json` for automation or `--output output-contract.json` to write the contract to disk. `doctor corpus` runs the bundled validation corpus against healthy runtime, risky security, starter skill, and generic MCP packages, then reports whether each case matched its expected outcome. Add `--json` for automation or `--output validation-corpus.json` to write the corpus report to disk. `doctor npm <package>` runs a preinstall scan by packing the npm package with scripts disabled, extracting the publish tarball, and running validation, security, trust, and recommendation checks against the shipped contents. The JSON report includes tarball metadata such as filename, path, integrity, shasum, sizes, file count, and extracted package root so automation can prove which archive was scanned. Use a published Codex plugin package as the target; scanning `codex-plugin-doctor` itself intentionally reports a missing plugin manifest because this CLI package is not a plugin package. Add `--json` for automation or `--output npm-preinstall.json` to write the report to disk. `doctor attest <path>` creates a local attestation with stable package/report digests, validation/security/compatibility/trust summary, and verification metadata. Add `--sign-key-env NAME` to attach a local HMAC-SHA256 signature without printing the secret, or `--json --output attestation.json` to write the artifact to disk. `doctor attest verify <attestation.json> --target <path> --sign-key-env NAME` recomputes the package fingerprint, report digest, and HMAC signature offline; verification intentionally treats `generatedAt`, `targetPath`, `verification`, and `signature.keyHint` as unsigned display metadata. `doctor runtime-plan <path>` creates a non-executing runtime plan that lists MCP server commands, safe probe methods, risk reasons, and a stable approval digest before any local server is started. Add `--markdown --output runtime-plan.md` to preserve a review-ready approval artifact with the execution boundary, checklist, servers, probes, and risk reasons. `doctor runtime-policy <path>` evaluates the same runtime plan and security signals, then recommends `allow`, `review`, `sandbox_recommended`, or `deny` before local MCP execution starts. `doctor review-bundle <path> --output <dir> --sign-key-env NAME` writes a signed review directory with runtime plan, runtime policy, attestation, release evidence, manifest, Markdown summary files, and SHA-256 file integrity digests. `doctor review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME` verifies the bundle manifest, expected files, manifest integrity digests, runtime artifacts, signed attestation, and signed release evidence offline before a reviewer trusts the handoff. `doctor review-bundle diff --before <dir> --after <dir>` compares two review bundles and flags risk-increasing changes in status, runtime policy, release readiness, signatures, release evidence, and runtime plan digest. `check --runtime --require-runtime-approval --runtime-approval-digest <digest>` refuses to run runtime probes unless the current plan digest matches the approved digest. `doctor release-evidence <path> --sign-key-env NAME` creates one redacted release bundle with signed attestation, offline verification, corpus, performance, security, trust, package metadata, git release gates, and runtime approval status. Strict release evidence requires a clean tagged worktree; use `--allow-dirty` or `--allow-untagged` only for local rehearsal. `doctor release-evidence verify <evidence.json> --target <path> --sign-key-env NAME` verifies a shared release evidence artifact offline against an explicit package path; the artifact target path is treated as display metadata, not trusted input. `doctor release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME` writes a signed release evidence file and prints the `gh release upload` command; add `--upload` to run the upload through GitHub CLI with `--clobber`. `doctor inspector <path>` builds a safe MCP Inspector launch command from a packaged `.mcp.json` file without starting the Inspector proxy automatically. Use `--server <name>` when the package contains multiple MCP server entries. `doctor diff --before <path> --after <path>` compares two package roots and reports new findings, resolved findings, trust score delta, and whether risk increased. `doctor recommend <path>` turns validation, security, and compatibility signals into a prioritized action plan with blocker, high, medium, and info actions. Add `--json` for automation or `--output recommendations.json` to write the report to disk. `doctor trust <path>` creates a local trust score from package lifecycle scripts, dependency specs, and MCP security findings. Use it before release when you want supply-chain risks summarized as one score. `doctor perf <path>` profiles the shared package analysis pipeline and reports per-stage durations for validation, config, security, compatibility, trust, recommendations, and total runtime. Add `--max-total-ms <ms>` or repeatable `--max-stage-ms stage=ms` to fail CI when a budget is exceeded. `doctor mcp <path>` exposes the generic MCP static health report under the doctor command family without starting local MCP servers. `doctor export --bundle <path>` creates a redacted operator handoff bundle that includes validation JSON, security scorecard data, compatibility matrix, recommendations, and trust score in one file. `doctor snapshot` creates a redacted diagnostics bundle with environment health, client config readiness, installed plugin metadata, and next commands. Add `--json` for machine-readable output or `--output doctor-snapshot.json` to write the bundle to disk. `doctor clients` reports local Codex, Claude Desktop, Cursor, Cline, and Windsurf config readiness. `doctor --update-check` compares the installed CLI version with the latest npm version and prints the upgrade command when a newer release is available.
@@ -421,9 +427,9 @@ jobs:
421
427
  runs-on: ubuntu-latest
422
428
  steps:
423
429
  - uses: actions/checkout@v5
424
- - uses: Esquetta/CodexPluginDoctor@v1.47.0
430
+ - uses: Esquetta/CodexPluginDoctor@v1.49.0
425
431
  with:
426
- version: "1.47.0"
432
+ version: "1.49.0"
427
433
  path: .
428
434
  runtime: "true"
429
435
  policy: codex-publish
@@ -0,0 +1,193 @@
1
+ import type { ExternalCorpusMode, ExternalCorpusProfile, ExternalCorpusSourceType, FindingReviewClassification } from "./external-validation-corpus.js";
2
+ export interface CorpusMetricThresholds {
3
+ minPrecision?: number;
4
+ minRecall?: number;
5
+ maxFalsePositiveRate?: number;
6
+ }
7
+ export interface CorpusMetricFindingReview {
8
+ findingId: string;
9
+ fingerprint: string;
10
+ classification: FindingReviewClassification;
11
+ }
12
+ export interface CorpusMetricSource {
13
+ repository: string;
14
+ revision: string;
15
+ }
16
+ export interface CorpusMetricTarget {
17
+ id: string;
18
+ profile: ExternalCorpusProfile;
19
+ sourceType: ExternalCorpusSourceType;
20
+ disclosure: "anonymized";
21
+ path: string;
22
+ mode: ExternalCorpusMode;
23
+ contentDigest: string;
24
+ source?: CorpusMetricSource;
25
+ reviews: CorpusMetricFindingReview[];
26
+ }
27
+ export interface CorpusMetricsManifest {
28
+ schemaVersion: "1.0.0";
29
+ thresholds?: CorpusMetricThresholds;
30
+ targets: CorpusMetricTarget[];
31
+ }
32
+ interface LoadedCorpusMetricTarget extends CorpusMetricTarget {
33
+ resolvedPath: string;
34
+ }
35
+ interface LoadedCorpusMetricsManifest extends CorpusMetricsManifest {
36
+ targets: LoadedCorpusMetricTarget[];
37
+ }
38
+ export interface CorpusMetricFinding {
39
+ findingId: string;
40
+ fingerprint: string;
41
+ }
42
+ export interface CorpusMetricCounts {
43
+ truePositives: number;
44
+ falsePositives: number;
45
+ falseNegatives: number;
46
+ resolvedFalsePositives: number;
47
+ unreviewed: number;
48
+ unclear: number;
49
+ }
50
+ export interface CorpusMetricValues {
51
+ precision: number | null;
52
+ recall: number | null;
53
+ falsePositiveRate: number | null;
54
+ }
55
+ export interface CorpusMetricOutcome extends CorpusMetricFinding {
56
+ classification: FindingReviewClassification | "unreviewed";
57
+ emitted: boolean;
58
+ }
59
+ export interface CorpusMetricReconciliation {
60
+ counts: CorpusMetricCounts;
61
+ metrics: CorpusMetricValues;
62
+ complete: boolean;
63
+ outcomes: CorpusMetricOutcome[];
64
+ }
65
+ export interface CorpusMetricThresholdCheck {
66
+ metric: keyof CorpusMetricValues;
67
+ operator: ">=" | "<=";
68
+ threshold: number;
69
+ actual: number | null;
70
+ passed: boolean;
71
+ }
72
+ export interface CorpusQualityMetricsTargetResult {
73
+ id: string;
74
+ profile: ExternalCorpusProfile;
75
+ sourceType: ExternalCorpusSourceType;
76
+ disclosure: "anonymized";
77
+ mode: ExternalCorpusMode;
78
+ source?: CorpusMetricSource;
79
+ digestMatched: boolean;
80
+ counts: CorpusMetricCounts;
81
+ metrics: CorpusMetricValues;
82
+ complete: boolean;
83
+ outcomes: CorpusMetricOutcome[];
84
+ }
85
+ export interface CorpusQualityMetricsReport {
86
+ schemaVersion: "1.0.0";
87
+ kind: "doctor.validation.corpus.metrics";
88
+ generatedAt: string;
89
+ version: string;
90
+ corpusDigest: string;
91
+ status: "pass" | "fail" | "incomplete";
92
+ exitCode: 0 | 1 | 2;
93
+ summary: CorpusMetricCounts & CorpusMetricValues & {
94
+ targetCount: number;
95
+ completeTargets: number;
96
+ incompleteTargets: number;
97
+ };
98
+ thresholds: CorpusMetricThresholds;
99
+ thresholdChecks: CorpusMetricThresholdCheck[];
100
+ targets: CorpusQualityMetricsTargetResult[];
101
+ }
102
+ export interface CorpusMetricCountDeltas {
103
+ truePositives: number;
104
+ falsePositives: number;
105
+ falseNegatives: number;
106
+ resolvedFalsePositives: number;
107
+ unreviewed: number;
108
+ unclear: number;
109
+ }
110
+ export interface CorpusMetricValueComparison {
111
+ before: number | null;
112
+ after: number | null;
113
+ delta: number | null;
114
+ }
115
+ export interface CorpusQualityMetricsDiffTarget {
116
+ id: string;
117
+ regressed: boolean;
118
+ counts: CorpusMetricCountDeltas;
119
+ precision: CorpusMetricValueComparison;
120
+ recall: CorpusMetricValueComparison;
121
+ falsePositiveRate: CorpusMetricValueComparison;
122
+ }
123
+ export interface CorpusQualityMetricsDiffReport {
124
+ schemaVersion: "1.0.0";
125
+ kind: "doctor.validation.corpus.metrics.diff";
126
+ generatedAt: string;
127
+ version: string;
128
+ corpusDigest: string;
129
+ status: "pass" | "fail";
130
+ exitCode: 0 | 1;
131
+ failOnRegression: boolean;
132
+ summary: {
133
+ comparable: true;
134
+ regression: boolean;
135
+ changedTargets: number;
136
+ precisionBefore: number | null;
137
+ precisionAfter: number | null;
138
+ precisionDelta: number | null;
139
+ recallBefore: number | null;
140
+ recallAfter: number | null;
141
+ recallDelta: number | null;
142
+ falsePositiveRateBefore: number | null;
143
+ falsePositiveRateAfter: number | null;
144
+ falsePositiveRateDelta: number | null;
145
+ counts: CorpusMetricCountDeltas;
146
+ };
147
+ before: {
148
+ version: string;
149
+ targetCount: number;
150
+ };
151
+ after: {
152
+ version: string;
153
+ targetCount: number;
154
+ };
155
+ targets: CorpusQualityMetricsDiffTarget[];
156
+ }
157
+ export interface BuildCorpusMetricsDiffOptions {
158
+ failOnRegression?: boolean;
159
+ }
160
+ export interface BuildCorpusMetricsOptions {
161
+ thresholds?: CorpusMetricThresholds;
162
+ environment?: {
163
+ env?: NodeJS.ProcessEnv;
164
+ platform?: NodeJS.Platform;
165
+ };
166
+ analyzeTarget?: (targetPath: string, mode: ExternalCorpusMode) => Promise<{
167
+ findings: CorpusMetricFinding[];
168
+ }>;
169
+ buildFingerprint?: (targetPath: string) => Promise<{
170
+ digest: string;
171
+ }>;
172
+ }
173
+ export declare class CorpusMetricsManifestError extends Error {
174
+ constructor(message: string);
175
+ }
176
+ export declare class CorpusMetricsDiffError extends Error {
177
+ constructor(message: string);
178
+ }
179
+ export declare function loadCorpusMetricsManifest(manifestPath: string): Promise<LoadedCorpusMetricsManifest>;
180
+ export declare function reconcileCorpusMetricFindings(reviews: CorpusMetricFindingReview[], actualFindings: CorpusMetricFinding[]): CorpusMetricReconciliation;
181
+ export declare function buildCorpusQualityMetricsReport(manifestPath: string, options?: BuildCorpusMetricsOptions): Promise<CorpusQualityMetricsReport>;
182
+ export declare function renderCorpusQualityMetricsJson(report: CorpusQualityMetricsReport): string;
183
+ export declare function renderCorpusQualityMetricsText(report: CorpusQualityMetricsReport, options?: {
184
+ outputPath?: string | null;
185
+ }): string;
186
+ export declare function renderCorpusQualityMetricsMarkdown(report: CorpusQualityMetricsReport): string;
187
+ export declare function buildCorpusQualityMetricsDiffReport(beforePath: string, afterPath: string, options?: BuildCorpusMetricsDiffOptions): Promise<CorpusQualityMetricsDiffReport>;
188
+ export declare function renderCorpusQualityMetricsDiffJson(report: CorpusQualityMetricsDiffReport): string;
189
+ export declare function renderCorpusQualityMetricsDiffText(report: CorpusQualityMetricsDiffReport, options?: {
190
+ outputPath?: string | null;
191
+ }): string;
192
+ export declare function renderCorpusQualityMetricsDiffMarkdown(report: CorpusQualityMetricsDiffReport): string;
193
+ export {};
@@ -0,0 +1,722 @@
1
+ import { createHash } from "node:crypto";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { buildGenericMcpDoctor } from "../mcp/generic-mcp-doctor.js";
5
+ import { packageVersion } from "../version.js";
6
+ import { buildPackageFingerprint } from "./attestation.js";
7
+ import { readJsonFile } from "./read-json-file.js";
8
+ import { validatePlugin } from "./validate-plugin.js";
9
+ export class CorpusMetricsManifestError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "CorpusMetricsManifestError";
13
+ }
14
+ }
15
+ export class CorpusMetricsDiffError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "CorpusMetricsDiffError";
19
+ }
20
+ }
21
+ const manifestKeys = new Set(["schemaVersion", "thresholds", "targets"]);
22
+ const thresholdKeys = new Set(["minPrecision", "minRecall", "maxFalsePositiveRate"]);
23
+ const targetKeys = new Set([
24
+ "id", "profile", "sourceType", "disclosure", "path", "mode",
25
+ "contentDigest", "source", "reviews"
26
+ ]);
27
+ const sourceKeys = new Set(["repository", "revision"]);
28
+ const reviewKeys = new Set(["findingId", "fingerprint", "classification"]);
29
+ const profiles = new Set(["healthy", "broken", "edge-case"]);
30
+ const sourceTypes = new Set([
31
+ "public-package", "local-snapshot", "derived-fixture"
32
+ ]);
33
+ const modes = new Set(["codex-plugin", "generic-mcp"]);
34
+ const classifications = new Set([
35
+ "true_positive", "false_positive", "unclear"
36
+ ]);
37
+ const sha256Digest = /^sha256:[a-f0-9]{64}$/;
38
+ const fingerprintPattern = /^[a-f0-9]{64}$/;
39
+ const immutableRevision = /^[a-f0-9]{40}([a-f0-9]{24})?$/;
40
+ const publicSafeId = /^[a-z0-9][a-z0-9._-]{0,63}$/;
41
+ function isRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+ function rejectUnknownKeys(value, allowed, label) {
45
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
46
+ if (unknown.length > 0) {
47
+ throw new CorpusMetricsManifestError(`${label} contains unsupported field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`);
48
+ }
49
+ }
50
+ function requireString(value, label) {
51
+ if (typeof value !== "string" || value.length === 0) {
52
+ throw new CorpusMetricsManifestError(`${label} must be a non-empty string.`);
53
+ }
54
+ return value;
55
+ }
56
+ function parseThresholds(value) {
57
+ if (value === undefined)
58
+ return undefined;
59
+ if (!isRecord(value))
60
+ throw new CorpusMetricsManifestError("Metrics thresholds must be an object.");
61
+ rejectUnknownKeys(value, thresholdKeys, "Metrics thresholds");
62
+ const result = {};
63
+ for (const key of thresholdKeys) {
64
+ const candidate = value[key];
65
+ if (candidate === undefined)
66
+ continue;
67
+ if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate < 0 || candidate > 1) {
68
+ throw new CorpusMetricsManifestError(`Metrics threshold ${key} must be between 0 and 1.`);
69
+ }
70
+ result[key] = candidate;
71
+ }
72
+ return result;
73
+ }
74
+ function parseSource(value, targetId) {
75
+ if (value === undefined)
76
+ return undefined;
77
+ if (!isRecord(value))
78
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source must be an object.`);
79
+ rejectUnknownKeys(value, sourceKeys, `Metrics target ${targetId} source`);
80
+ const repository = requireString(value.repository, `Metrics target ${targetId} source repository`);
81
+ const revision = requireString(value.revision, `Metrics target ${targetId} source revision`);
82
+ let parsed;
83
+ try {
84
+ parsed = new URL(repository);
85
+ }
86
+ catch {
87
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source repository must use HTTPS.`);
88
+ }
89
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) {
90
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source repository must use HTTPS.`);
91
+ }
92
+ if (!immutableRevision.test(revision)) {
93
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source revision must be immutable.`);
94
+ }
95
+ return { repository, revision };
96
+ }
97
+ function parseReview(value, targetId, index) {
98
+ if (!isRecord(value))
99
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} review ${index} must be an object.`);
100
+ rejectUnknownKeys(value, reviewKeys, `Metrics target ${targetId} review ${index}`);
101
+ const findingId = requireString(value.findingId, `Metrics target ${targetId} review ${index} findingId`);
102
+ const fingerprint = requireString(value.fingerprint, `Metrics target ${targetId} review ${index} fingerprint`);
103
+ if (!fingerprintPattern.test(fingerprint)) {
104
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} review ${index} fingerprint must be a sha256 fingerprint.`);
105
+ }
106
+ if (typeof value.classification !== "string" ||
107
+ !classifications.has(value.classification)) {
108
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} review ${index} has an unsupported classification.`);
109
+ }
110
+ return { findingId, fingerprint, classification: value.classification };
111
+ }
112
+ function resolveContainedTarget(manifestDirectory, targetPath) {
113
+ if (path.posix.isAbsolute(targetPath) || path.win32.isAbsolute(targetPath)) {
114
+ throw new CorpusMetricsManifestError("Metrics target path must be relative to the manifest file.");
115
+ }
116
+ const root = path.resolve(manifestDirectory);
117
+ const resolved = path.resolve(root, targetPath);
118
+ const relative = path.relative(root, resolved);
119
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
120
+ throw new CorpusMetricsManifestError("Metrics target path must remain beneath the manifest directory.");
121
+ }
122
+ return resolved;
123
+ }
124
+ async function parseTarget(value, manifestDirectory, index) {
125
+ if (!isRecord(value))
126
+ throw new CorpusMetricsManifestError(`Metrics target ${index} must be an object.`);
127
+ rejectUnknownKeys(value, targetKeys, `Metrics target ${index}`);
128
+ const id = requireString(value.id, `Metrics target ${index} id`);
129
+ if (!publicSafeId.test(id)) {
130
+ throw new CorpusMetricsManifestError(`Metrics target ${index} id must be public-safe.`);
131
+ }
132
+ const targetPath = requireString(value.path, `Metrics target ${id} path`);
133
+ if (typeof value.profile !== "string" || !profiles.has(value.profile)) {
134
+ throw new CorpusMetricsManifestError(`Metrics target ${id} has an unsupported profile.`);
135
+ }
136
+ if (typeof value.sourceType !== "string" || !sourceTypes.has(value.sourceType)) {
137
+ throw new CorpusMetricsManifestError(`Metrics target ${id} has an unsupported sourceType.`);
138
+ }
139
+ if (value.disclosure !== "anonymized") {
140
+ throw new CorpusMetricsManifestError(`Metrics target ${id} disclosure must be anonymized.`);
141
+ }
142
+ if (typeof value.mode !== "string" || !modes.has(value.mode)) {
143
+ throw new CorpusMetricsManifestError(`Metrics target ${id} has an unsupported mode.`);
144
+ }
145
+ if (typeof value.contentDigest !== "string" || !sha256Digest.test(value.contentDigest)) {
146
+ throw new CorpusMetricsManifestError(`Metrics target ${id} contentDigest must be a sha256 digest.`);
147
+ }
148
+ if (!Array.isArray(value.reviews)) {
149
+ throw new CorpusMetricsManifestError(`Metrics target ${id} reviews must be an array.`);
150
+ }
151
+ const reviews = value.reviews.map((review, reviewIndex) => parseReview(review, id, reviewIndex));
152
+ const reviewKeysSeen = new Set();
153
+ for (const review of reviews) {
154
+ const key = findingKey(review.findingId, review.fingerprint);
155
+ if (reviewKeysSeen.has(key)) {
156
+ throw new CorpusMetricsManifestError(`Metrics target ${id} contains a duplicate finding review.`);
157
+ }
158
+ reviewKeysSeen.add(key);
159
+ }
160
+ const resolvedPath = resolveContainedTarget(manifestDirectory, targetPath);
161
+ try {
162
+ if (!(await stat(resolvedPath)).isDirectory())
163
+ throw new Error("not a directory");
164
+ }
165
+ catch {
166
+ throw new CorpusMetricsManifestError(`Metrics target ${id} directory does not exist.`);
167
+ }
168
+ const source = parseSource(value.source, id);
169
+ return {
170
+ id,
171
+ profile: value.profile,
172
+ sourceType: value.sourceType,
173
+ disclosure: "anonymized",
174
+ path: targetPath,
175
+ mode: value.mode,
176
+ contentDigest: value.contentDigest,
177
+ ...(source ? { source } : {}),
178
+ reviews,
179
+ resolvedPath
180
+ };
181
+ }
182
+ export async function loadCorpusMetricsManifest(manifestPath) {
183
+ let value;
184
+ try {
185
+ value = await readJsonFile(path.resolve(manifestPath));
186
+ }
187
+ catch {
188
+ throw new CorpusMetricsManifestError("Metrics manifest could not be read as JSON.");
189
+ }
190
+ if (!isRecord(value))
191
+ throw new CorpusMetricsManifestError("Metrics manifest must be an object.");
192
+ rejectUnknownKeys(value, manifestKeys, "Metrics manifest");
193
+ if (value.schemaVersion !== "1.0.0") {
194
+ throw new CorpusMetricsManifestError("Unsupported metrics manifest schemaVersion.");
195
+ }
196
+ if (!Array.isArray(value.targets) || value.targets.length === 0) {
197
+ throw new CorpusMetricsManifestError("Metrics manifest targets must be a non-empty array.");
198
+ }
199
+ const manifestDirectory = path.dirname(path.resolve(manifestPath));
200
+ const targets = await Promise.all(value.targets.map((target, index) => parseTarget(target, manifestDirectory, index)));
201
+ const ids = new Set();
202
+ for (const target of targets) {
203
+ if (ids.has(target.id))
204
+ throw new CorpusMetricsManifestError(`Metrics target id ${target.id} is duplicated.`);
205
+ ids.add(target.id);
206
+ }
207
+ const thresholds = parseThresholds(value.thresholds);
208
+ return { schemaVersion: "1.0.0", ...(thresholds ? { thresholds } : {}), targets };
209
+ }
210
+ function findingKey(findingId, fingerprint) {
211
+ return `${findingId}:${fingerprint}`;
212
+ }
213
+ function roundMetric(value) {
214
+ return value === null ? null : Math.round(value * 1_000_000) / 1_000_000;
215
+ }
216
+ function buildCorpusDigest(manifest) {
217
+ const identity = manifest.targets
218
+ .map((target) => ({
219
+ id: target.id,
220
+ profile: target.profile,
221
+ sourceType: target.sourceType,
222
+ mode: target.mode,
223
+ contentDigest: target.contentDigest,
224
+ reviews: target.reviews
225
+ .map((review) => ({
226
+ findingId: review.findingId,
227
+ fingerprint: review.fingerprint,
228
+ classification: review.classification
229
+ }))
230
+ .sort((left, right) => findingKey(left.findingId, left.fingerprint)
231
+ .localeCompare(findingKey(right.findingId, right.fingerprint)))
232
+ }))
233
+ .sort((left, right) => left.id.localeCompare(right.id));
234
+ return `sha256:${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}`;
235
+ }
236
+ function calculateRawMetrics(counts) {
237
+ const precisionDenominator = counts.truePositives + counts.falsePositives;
238
+ const recallDenominator = counts.truePositives + counts.falseNegatives;
239
+ return {
240
+ precision: precisionDenominator === 0 ? null : counts.truePositives / precisionDenominator,
241
+ recall: recallDenominator === 0 ? null : counts.truePositives / recallDenominator,
242
+ falsePositiveRate: precisionDenominator === 0 ? null : counts.falsePositives / precisionDenominator
243
+ };
244
+ }
245
+ function calculateMetrics(counts) {
246
+ const raw = calculateRawMetrics(counts);
247
+ return {
248
+ precision: roundMetric(raw.precision),
249
+ recall: roundMetric(raw.recall),
250
+ falsePositiveRate: roundMetric(raw.falsePositiveRate)
251
+ };
252
+ }
253
+ export function reconcileCorpusMetricFindings(reviews, actualFindings) {
254
+ const reviewsByKey = new Map(reviews.map((review) => [findingKey(review.findingId, review.fingerprint), review]));
255
+ const actualKeys = new Set(actualFindings.map((finding) => findingKey(finding.findingId, finding.fingerprint)));
256
+ const outcomes = actualFindings.map((finding) => ({
257
+ ...finding,
258
+ classification: reviewsByKey.get(findingKey(finding.findingId, finding.fingerprint))?.classification ?? "unreviewed",
259
+ emitted: true
260
+ }));
261
+ for (const review of reviews) {
262
+ if (!actualKeys.has(findingKey(review.findingId, review.fingerprint))) {
263
+ outcomes.push({ ...review, emitted: false });
264
+ }
265
+ }
266
+ outcomes.sort((left, right) => findingKey(left.findingId, left.fingerprint)
267
+ .localeCompare(findingKey(right.findingId, right.fingerprint)));
268
+ const counts = {
269
+ truePositives: outcomes.filter((item) => item.emitted && item.classification === "true_positive").length,
270
+ falsePositives: outcomes.filter((item) => item.emitted && item.classification === "false_positive").length,
271
+ falseNegatives: outcomes.filter((item) => !item.emitted && item.classification === "true_positive").length,
272
+ resolvedFalsePositives: outcomes.filter((item) => !item.emitted && item.classification === "false_positive").length,
273
+ unreviewed: outcomes.filter((item) => item.emitted && item.classification === "unreviewed").length,
274
+ unclear: outcomes.filter((item) => item.classification === "unclear").length
275
+ };
276
+ return {
277
+ counts,
278
+ metrics: calculateMetrics(counts),
279
+ complete: counts.unreviewed === 0 && counts.unclear === 0,
280
+ outcomes
281
+ };
282
+ }
283
+ function addCounts(left, right) {
284
+ return {
285
+ truePositives: left.truePositives + right.truePositives,
286
+ falsePositives: left.falsePositives + right.falsePositives,
287
+ falseNegatives: left.falseNegatives + right.falseNegatives,
288
+ resolvedFalsePositives: left.resolvedFalsePositives + right.resolvedFalsePositives,
289
+ unreviewed: left.unreviewed + right.unreviewed,
290
+ unclear: left.unclear + right.unclear
291
+ };
292
+ }
293
+ async function defaultAnalyzeTarget(targetPath, mode, environment) {
294
+ const report = mode === "generic-mcp"
295
+ ? await buildGenericMcpDoctor(targetPath, environment)
296
+ : await validatePlugin(targetPath);
297
+ return {
298
+ findings: report.findings.map((finding) => {
299
+ if (!finding.fingerprint) {
300
+ throw new CorpusMetricsManifestError("Metrics analysis produced a finding without a fingerprint.");
301
+ }
302
+ return { findingId: finding.id, fingerprint: finding.fingerprint };
303
+ })
304
+ };
305
+ }
306
+ function buildThresholdChecks(metrics, thresholds) {
307
+ const definitions = [
308
+ ["minPrecision", "precision", ">="],
309
+ ["minRecall", "recall", ">="],
310
+ ["maxFalsePositiveRate", "falsePositiveRate", "<="]
311
+ ];
312
+ return definitions.flatMap(([thresholdKey, metric, operator]) => {
313
+ const threshold = thresholds[thresholdKey];
314
+ if (threshold === undefined)
315
+ return [];
316
+ const actual = metrics[metric];
317
+ const passed = actual !== null && (operator === ">=" ? actual >= threshold : actual <= threshold);
318
+ return [{ metric, operator, threshold, actual, passed }];
319
+ });
320
+ }
321
+ export async function buildCorpusQualityMetricsReport(manifestPath, options = {}) {
322
+ const manifest = await loadCorpusMetricsManifest(manifestPath);
323
+ const thresholds = { ...(manifest.thresholds ?? {}), ...(options.thresholds ?? {}) };
324
+ const analyzeTarget = options.analyzeTarget ?? ((targetPath, mode) => defaultAnalyzeTarget(targetPath, mode, options.environment));
325
+ const fingerprintBuilder = options.buildFingerprint ?? buildPackageFingerprint;
326
+ const targets = [];
327
+ for (const target of manifest.targets) {
328
+ const [analysis, fingerprint] = await Promise.all([
329
+ analyzeTarget(target.resolvedPath, target.mode),
330
+ fingerprintBuilder(target.resolvedPath)
331
+ ]);
332
+ const reconciliation = reconcileCorpusMetricFindings(target.reviews, analysis.findings);
333
+ const digestMatched = fingerprint.digest === target.contentDigest;
334
+ targets.push({
335
+ id: target.id,
336
+ profile: target.profile,
337
+ sourceType: target.sourceType,
338
+ disclosure: target.disclosure,
339
+ mode: target.mode,
340
+ ...(target.source ? { source: target.source } : {}),
341
+ digestMatched,
342
+ counts: reconciliation.counts,
343
+ metrics: reconciliation.metrics,
344
+ complete: reconciliation.complete && digestMatched,
345
+ outcomes: reconciliation.outcomes
346
+ });
347
+ }
348
+ const emptyCounts = {
349
+ truePositives: 0, falsePositives: 0, falseNegatives: 0,
350
+ resolvedFalsePositives: 0, unreviewed: 0, unclear: 0
351
+ };
352
+ const counts = targets.reduce((total, target) => addCounts(total, target.counts), emptyCounts);
353
+ const metrics = calculateMetrics(counts);
354
+ const rawMetrics = calculateRawMetrics(counts);
355
+ const completeTargets = targets.filter((target) => target.complete).length;
356
+ const thresholdChecks = buildThresholdChecks(rawMetrics, thresholds).map((check) => ({
357
+ ...check,
358
+ actual: metrics[check.metric]
359
+ }));
360
+ const incomplete = completeTargets !== targets.length;
361
+ const failedThreshold = thresholdChecks.some((check) => !check.passed);
362
+ const status = incomplete ? "incomplete" : failedThreshold ? "fail" : "pass";
363
+ const exitCode = incomplete ? 2 : failedThreshold ? 1 : 0;
364
+ return {
365
+ schemaVersion: "1.0.0",
366
+ kind: "doctor.validation.corpus.metrics",
367
+ generatedAt: new Date().toISOString(),
368
+ version: packageVersion,
369
+ corpusDigest: buildCorpusDigest(manifest),
370
+ status,
371
+ exitCode,
372
+ summary: {
373
+ targetCount: targets.length,
374
+ completeTargets,
375
+ incompleteTargets: targets.length - completeTargets,
376
+ ...counts,
377
+ ...metrics
378
+ },
379
+ thresholds,
380
+ thresholdChecks,
381
+ targets
382
+ };
383
+ }
384
+ function formatMetric(value) {
385
+ return value === null ? "N/A" : `${(value * 100).toFixed(2)}%`;
386
+ }
387
+ function metricLabel(metric) {
388
+ if (metric === "falsePositiveRate")
389
+ return "False-positive rate";
390
+ return metric[0].toUpperCase() + metric.slice(1);
391
+ }
392
+ export function renderCorpusQualityMetricsJson(report) {
393
+ return JSON.stringify(report, null, 2);
394
+ }
395
+ export function renderCorpusQualityMetricsText(report, options = {}) {
396
+ const lines = [
397
+ "Doctor Corpus Quality Metrics",
398
+ "=============================",
399
+ `Version: ${report.version}`,
400
+ `Status: ${report.status.toUpperCase()}`,
401
+ `Targets: ${report.summary.targetCount}`,
402
+ `Precision: ${formatMetric(report.summary.precision)}`,
403
+ `Recall: ${formatMetric(report.summary.recall)}`,
404
+ `False-positive rate: ${formatMetric(report.summary.falsePositiveRate)}`,
405
+ `TP: ${report.summary.truePositives} FP: ${report.summary.falsePositives} FN: ${report.summary.falseNegatives}`
406
+ ];
407
+ if (options.outputPath)
408
+ lines.push(`Output: ${options.outputPath}`);
409
+ const failed = report.thresholdChecks.filter((check) => !check.passed);
410
+ lines.push("", "Failed thresholds", "-----------------");
411
+ if (failed.length === 0) {
412
+ lines.push("None.");
413
+ }
414
+ else {
415
+ for (const check of failed) {
416
+ lines.push(`${metricLabel(check.metric)}: ${formatMetric(check.actual)} ${check.operator} ${formatMetric(check.threshold)} required`);
417
+ }
418
+ }
419
+ lines.push("", "Targets", "-------");
420
+ for (const target of report.targets) {
421
+ lines.push(`${target.id}: ${target.complete ? "COMPLETE" : "INCOMPLETE"} ` +
422
+ `(precision ${formatMetric(target.metrics.precision)}, recall ${formatMetric(target.metrics.recall)})`);
423
+ }
424
+ return lines.join("\n");
425
+ }
426
+ export function renderCorpusQualityMetricsMarkdown(report) {
427
+ const lines = [
428
+ "# Doctor Corpus Quality Metrics",
429
+ "",
430
+ `- Version: ${report.version}`,
431
+ `- Status: ${report.status.toUpperCase()}`,
432
+ `- Targets: ${report.summary.targetCount}`,
433
+ "",
434
+ "| Metric | Value |",
435
+ "| --- | ---: |",
436
+ `| Precision | ${formatMetric(report.summary.precision)} |`,
437
+ `| Recall | ${formatMetric(report.summary.recall)} |`,
438
+ `| False-positive rate | ${formatMetric(report.summary.falsePositiveRate)} |`,
439
+ `| True positives | ${report.summary.truePositives} |`,
440
+ `| False positives | ${report.summary.falsePositives} |`,
441
+ `| False negatives | ${report.summary.falseNegatives} |`,
442
+ "",
443
+ "## Failed Thresholds",
444
+ ""
445
+ ];
446
+ const failed = report.thresholdChecks.filter((check) => !check.passed);
447
+ if (failed.length === 0) {
448
+ lines.push("None.");
449
+ }
450
+ else {
451
+ for (const check of failed) {
452
+ lines.push(`- ${metricLabel(check.metric)}: ${formatMetric(check.actual)} ${check.operator} ${formatMetric(check.threshold)} required`);
453
+ }
454
+ }
455
+ lines.push("", "## Targets", "", "| Target | Review | Precision | Recall |", "| --- | --- | ---: | ---: |");
456
+ for (const target of report.targets) {
457
+ lines.push(`| ${target.id} | ${target.complete ? "complete" : "incomplete"} | ` +
458
+ `${formatMetric(target.metrics.precision)} | ${formatMetric(target.metrics.recall)} |`);
459
+ }
460
+ return lines.join("\n");
461
+ }
462
+ const reportSizeLimit = 5 * 1024 * 1024;
463
+ const countKeys = [
464
+ "truePositives",
465
+ "falsePositives",
466
+ "falseNegatives",
467
+ "resolvedFalsePositives",
468
+ "unreviewed",
469
+ "unclear"
470
+ ];
471
+ function requireNonNegativeInteger(value, label) {
472
+ if (!Number.isInteger(value) || value < 0) {
473
+ throw new CorpusMetricsDiffError(`${label} must be a non-negative integer.`);
474
+ }
475
+ return value;
476
+ }
477
+ function parseCounts(value, label) {
478
+ if (!isRecord(value))
479
+ throw new CorpusMetricsDiffError(`${label} is invalid.`);
480
+ return Object.fromEntries(countKeys.map((key) => [
481
+ key,
482
+ requireNonNegativeInteger(value[key], `${label}.${key}`)
483
+ ]));
484
+ }
485
+ function metricsMatchCounts(value, counts) {
486
+ if (!isRecord(value))
487
+ return false;
488
+ const expected = calculateMetrics(counts);
489
+ return value.precision === expected.precision &&
490
+ value.recall === expected.recall &&
491
+ value.falsePositiveRate === expected.falsePositiveRate;
492
+ }
493
+ async function loadComparableMetricsReport(reportPath, label) {
494
+ let fileStat;
495
+ try {
496
+ fileStat = await stat(path.resolve(reportPath));
497
+ }
498
+ catch {
499
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report could not be read.`);
500
+ }
501
+ if (!fileStat.isFile() || fileStat.size > reportSizeLimit) {
502
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report must be a file no larger than 5 MiB.`);
503
+ }
504
+ let value;
505
+ try {
506
+ value = await readJsonFile(path.resolve(reportPath));
507
+ }
508
+ catch {
509
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report could not be read as JSON.`);
510
+ }
511
+ if (!isRecord(value) || value.schemaVersion !== "1.0.0" ||
512
+ value.kind !== "doctor.validation.corpus.metrics") {
513
+ throw new CorpusMetricsDiffError(`${label} file is not a supported corpus metrics report.`);
514
+ }
515
+ if (typeof value.version !== "string" || value.version.length === 0) {
516
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report version is invalid.`);
517
+ }
518
+ if (typeof value.corpusDigest !== "string" || !sha256Digest.test(value.corpusDigest)) {
519
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report is missing a valid corpusDigest.`);
520
+ }
521
+ if (!isRecord(value.summary) || !Array.isArray(value.targets)) {
522
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report structure is invalid.`);
523
+ }
524
+ if (value.status === "incomplete" || value.summary.incompleteTargets !== 0 ||
525
+ value.summary.unreviewed !== 0 || value.summary.unclear !== 0) {
526
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report must be complete.`);
527
+ }
528
+ if ((value.status !== "pass" && value.status !== "fail") ||
529
+ (value.status === "pass" ? value.exitCode !== 0 : value.exitCode !== 1)) {
530
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report status is invalid.`);
531
+ }
532
+ const targetCount = requireNonNegativeInteger(value.summary.targetCount, `${label} summary.targetCount`);
533
+ const summaryCounts = parseCounts(value.summary, `${label} summary`);
534
+ if (targetCount === 0 || targetCount !== value.targets.length ||
535
+ value.summary.completeTargets !== targetCount || !metricsMatchCounts(value.summary, summaryCounts)) {
536
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report is internally inconsistent.`);
537
+ }
538
+ const ids = new Set();
539
+ const targets = value.targets.map((target, index) => {
540
+ if (!isRecord(target) || typeof target.id !== "string" || !publicSafeId.test(target.id) ||
541
+ target.complete !== true || target.digestMatched !== true) {
542
+ throw new CorpusMetricsDiffError(`${label} corpus metrics target ${index} is invalid or incomplete.`);
543
+ }
544
+ if (ids.has(target.id)) {
545
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report contains duplicate target IDs.`);
546
+ }
547
+ ids.add(target.id);
548
+ const counts = parseCounts(target.counts, `${label} target ${target.id} counts`);
549
+ if (!metricsMatchCounts(target.metrics, counts)) {
550
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report is internally inconsistent.`);
551
+ }
552
+ return { id: target.id, counts };
553
+ });
554
+ const summedCounts = targets.reduce((total, target) => addCounts(total, target.counts), {
555
+ truePositives: 0,
556
+ falsePositives: 0,
557
+ falseNegatives: 0,
558
+ resolvedFalsePositives: 0,
559
+ unreviewed: 0,
560
+ unclear: 0
561
+ });
562
+ if (countKeys.some((key) => summedCounts[key] !== summaryCounts[key])) {
563
+ throw new CorpusMetricsDiffError(`${label} corpus metrics report is internally inconsistent.`);
564
+ }
565
+ return {
566
+ version: value.version,
567
+ corpusDigest: value.corpusDigest,
568
+ summary: { ...summaryCounts, targetCount },
569
+ targets
570
+ };
571
+ }
572
+ function subtractCounts(after, before) {
573
+ return Object.fromEntries(countKeys.map((key) => [key, after[key] - before[key]]));
574
+ }
575
+ function compareMetric(before, after) {
576
+ return {
577
+ before: roundMetric(before),
578
+ after: roundMetric(after),
579
+ delta: before === null || after === null ? null : roundMetric(after - before)
580
+ };
581
+ }
582
+ function metricRegressed(precision, recall, falsePositiveRate) {
583
+ return (precision.delta !== null && precision.delta < 0) ||
584
+ (recall.delta !== null && recall.delta < 0) ||
585
+ (falsePositiveRate.delta !== null && falsePositiveRate.delta > 0);
586
+ }
587
+ export async function buildCorpusQualityMetricsDiffReport(beforePath, afterPath, options = {}) {
588
+ const [before, after] = await Promise.all([
589
+ loadComparableMetricsReport(beforePath, "Before"),
590
+ loadComparableMetricsReport(afterPath, "After")
591
+ ]);
592
+ const beforeIds = before.targets.map((target) => target.id).sort();
593
+ const afterIds = after.targets.map((target) => target.id).sort();
594
+ if (before.corpusDigest !== after.corpusDigest || JSON.stringify(beforeIds) !== JSON.stringify(afterIds)) {
595
+ throw new CorpusMetricsDiffError("Corpus metrics reports describe different corpus identities.");
596
+ }
597
+ const afterTargets = new Map(after.targets.map((target) => [target.id, target]));
598
+ const targets = before.targets.map((beforeTarget) => {
599
+ const afterTarget = afterTargets.get(beforeTarget.id);
600
+ const beforeMetrics = calculateRawMetrics(beforeTarget.counts);
601
+ const afterMetrics = calculateRawMetrics(afterTarget.counts);
602
+ const precision = compareMetric(beforeMetrics.precision, afterMetrics.precision);
603
+ const recall = compareMetric(beforeMetrics.recall, afterMetrics.recall);
604
+ const falsePositiveRate = compareMetric(beforeMetrics.falsePositiveRate, afterMetrics.falsePositiveRate);
605
+ return {
606
+ id: beforeTarget.id,
607
+ regressed: metricRegressed(precision, recall, falsePositiveRate),
608
+ counts: subtractCounts(afterTarget.counts, beforeTarget.counts),
609
+ precision,
610
+ recall,
611
+ falsePositiveRate
612
+ };
613
+ });
614
+ const changedTargets = targets.filter((target) => countKeys.some((key) => target.counts[key] !== 0)).length;
615
+ const beforeMetrics = calculateRawMetrics(before.summary);
616
+ const afterMetrics = calculateRawMetrics(after.summary);
617
+ const precision = compareMetric(beforeMetrics.precision, afterMetrics.precision);
618
+ const recall = compareMetric(beforeMetrics.recall, afterMetrics.recall);
619
+ const falsePositiveRate = compareMetric(beforeMetrics.falsePositiveRate, afterMetrics.falsePositiveRate);
620
+ const regression = metricRegressed(precision, recall, falsePositiveRate);
621
+ const failOnRegression = options.failOnRegression === true;
622
+ const failed = failOnRegression && regression;
623
+ return {
624
+ schemaVersion: "1.0.0",
625
+ kind: "doctor.validation.corpus.metrics.diff",
626
+ generatedAt: new Date().toISOString(),
627
+ version: packageVersion,
628
+ corpusDigest: before.corpusDigest,
629
+ status: failed ? "fail" : "pass",
630
+ exitCode: failed ? 1 : 0,
631
+ failOnRegression,
632
+ summary: {
633
+ comparable: true,
634
+ regression,
635
+ changedTargets,
636
+ precisionBefore: precision.before,
637
+ precisionAfter: precision.after,
638
+ precisionDelta: precision.delta,
639
+ recallBefore: recall.before,
640
+ recallAfter: recall.after,
641
+ recallDelta: recall.delta,
642
+ falsePositiveRateBefore: falsePositiveRate.before,
643
+ falsePositiveRateAfter: falsePositiveRate.after,
644
+ falsePositiveRateDelta: falsePositiveRate.delta,
645
+ counts: subtractCounts(after.summary, before.summary)
646
+ },
647
+ before: { version: before.version, targetCount: before.summary.targetCount },
648
+ after: { version: after.version, targetCount: after.summary.targetCount },
649
+ targets
650
+ };
651
+ }
652
+ function formatDelta(value) {
653
+ if (value === null)
654
+ return "N/A";
655
+ const sign = value > 0 ? "+" : "";
656
+ return `${sign}${(value * 100).toFixed(2)} pp`;
657
+ }
658
+ export function renderCorpusQualityMetricsDiffJson(report) {
659
+ return JSON.stringify(report, null, 2);
660
+ }
661
+ export function renderCorpusQualityMetricsDiffText(report, options = {}) {
662
+ const lines = [
663
+ "Doctor Corpus Metrics Diff",
664
+ "==========================",
665
+ `Status: ${report.status.toUpperCase()}`,
666
+ `Before version: ${report.before.version}`,
667
+ `After version: ${report.after.version}`,
668
+ `Regression: ${report.summary.regression ? "yes" : "no"}`,
669
+ `Precision delta: ${formatDelta(report.summary.precisionDelta)}`,
670
+ `Recall delta: ${formatDelta(report.summary.recallDelta)}`,
671
+ `False-positive rate delta: ${formatDelta(report.summary.falsePositiveRateDelta)}`,
672
+ `Changed targets: ${report.summary.changedTargets}`
673
+ ];
674
+ if (options.outputPath)
675
+ lines.push(`Output: ${options.outputPath}`);
676
+ lines.push("", "Target Changes", "--------------");
677
+ const changed = report.targets.filter((target) => countKeys.some((key) => target.counts[key] !== 0));
678
+ if (changed.length === 0) {
679
+ lines.push("None.");
680
+ }
681
+ else {
682
+ for (const target of changed) {
683
+ lines.push(`${target.id}: ${target.regressed ? "REGRESSED" : "CHANGED"} ` +
684
+ `(precision ${formatDelta(target.precision.delta)}, recall ${formatDelta(target.recall.delta)}, ` +
685
+ `false-positive rate ${formatDelta(target.falsePositiveRate.delta)})`);
686
+ }
687
+ }
688
+ return lines.join("\n");
689
+ }
690
+ export function renderCorpusQualityMetricsDiffMarkdown(report) {
691
+ const lines = [
692
+ "# Doctor Corpus Metrics Diff",
693
+ "",
694
+ `- Status: ${report.status.toUpperCase()}`,
695
+ `- Before version: ${report.before.version}`,
696
+ `- After version: ${report.after.version}`,
697
+ `- Regression: ${report.summary.regression ? "yes" : "no"}`,
698
+ "",
699
+ "| Metric | Before | After | Delta |",
700
+ "| --- | ---: | ---: | ---: |",
701
+ `| Precision | ${formatMetric(report.summary.precisionBefore)} | ${formatMetric(report.summary.precisionAfter)} | ${formatDelta(report.summary.precisionDelta)} |`,
702
+ `| Recall | ${formatMetric(report.summary.recallBefore)} | ${formatMetric(report.summary.recallAfter)} | ${formatDelta(report.summary.recallDelta)} |`,
703
+ `| False-positive rate | ${formatMetric(report.summary.falsePositiveRateBefore)} | ${formatMetric(report.summary.falsePositiveRateAfter)} | ${formatDelta(report.summary.falsePositiveRateDelta)} |`,
704
+ "",
705
+ "## Target Changes",
706
+ "",
707
+ "| Target | Result | Precision | Recall | False-positive rate |",
708
+ "| --- | --- | ---: | ---: | ---: |"
709
+ ];
710
+ const changed = report.targets.filter((target) => countKeys.some((key) => target.counts[key] !== 0));
711
+ if (changed.length === 0) {
712
+ lines.push("| None | unchanged | 0.00 pp | 0.00 pp | 0.00 pp |");
713
+ }
714
+ else {
715
+ for (const target of changed) {
716
+ lines.push(`| ${target.id} | ${target.regressed ? "regressed" : "changed"} | ` +
717
+ `${formatDelta(target.precision.delta)} | ${formatDelta(target.recall.delta)} | ` +
718
+ `${formatDelta(target.falsePositiveRate.delta)} |`);
719
+ }
720
+ }
721
+ return lines.join("\n");
722
+ }
@@ -101,6 +101,43 @@ const publicSchemaDefinitions = [
101
101
  outputKind: "doctor.validation.corpus",
102
102
  required: ["schemaVersion", "kind", "corpusType", "generatedAt", "version", "summary", "cases"]
103
103
  },
104
+ {
105
+ id: "doctor.validation.corpus.metrics.json",
106
+ command: "codex-plugin-doctor doctor corpus metrics --manifest <corpus.json> --json",
107
+ outputKind: "doctor.validation.corpus.metrics",
108
+ required: [
109
+ "schemaVersion",
110
+ "kind",
111
+ "generatedAt",
112
+ "version",
113
+ "corpusDigest",
114
+ "status",
115
+ "exitCode",
116
+ "summary",
117
+ "thresholds",
118
+ "thresholdChecks",
119
+ "targets"
120
+ ]
121
+ },
122
+ {
123
+ id: "doctor.validation.corpus.metrics.diff.json",
124
+ command: "codex-plugin-doctor doctor corpus metrics diff --before <metrics.json> --after <metrics.json> --json",
125
+ outputKind: "doctor.validation.corpus.metrics.diff",
126
+ required: [
127
+ "schemaVersion",
128
+ "kind",
129
+ "generatedAt",
130
+ "version",
131
+ "corpusDigest",
132
+ "status",
133
+ "exitCode",
134
+ "failOnRegression",
135
+ "summary",
136
+ "before",
137
+ "after",
138
+ "targets"
139
+ ]
140
+ },
104
141
  {
105
142
  id: "doctor.recommendations.json",
106
143
  command: "codex-plugin-doctor doctor recommend <path> --json",
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
11
11
  export { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson, type DoctorExportBundle } from "./core/doctor-export-bundle.js";
12
12
  export { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestationJson, renderDoctorAttestationVerification, renderDoctorAttestationVerificationJson, verifyDoctorAttestation, type DoctorAttestation, type DoctorAttestationVerificationCheck, type DoctorAttestationVerificationReport, type Digest, type PackageFingerprint } from "./core/attestation.js";
13
13
  export { buildExternalValidationCorpusReport, ExternalCorpusManifestError, loadExternalCorpusManifest, type BuildExternalValidationCorpusOptions, type ExternalCorpusCaseResult, type ExternalCorpusClassificationCounts, type ExternalCorpusFindingReview, type ExternalCorpusManifest, type ExternalCorpusMode, type ExternalCorpusProfile, type ExternalCorpusSourceType, type ExternalValidationCorpusReport, type FindingReviewClassification } from "./core/external-validation-corpus.js";
14
+ export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, loadCorpusMetricsManifest, reconcileCorpusMetricFindings, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText, type BuildCorpusMetricsDiffOptions, type BuildCorpusMetricsOptions, type CorpusMetricCounts, type CorpusMetricCountDeltas, type CorpusMetricFinding, type CorpusMetricFindingReview, type CorpusMetricReconciliation, type CorpusMetricsManifest, type CorpusMetricSource, type CorpusMetricThresholdCheck, type CorpusMetricThresholds, type CorpusMetricValues, type CorpusMetricValueComparison, type CorpusQualityMetricsDiffReport, type CorpusQualityMetricsDiffTarget, type CorpusQualityMetricsReport, type CorpusQualityMetricsTargetResult } from "./core/corpus-quality-metrics.js";
14
15
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson, type DoctorOutputContract, type OutputContractRule, type OutputContractSchema } from "./core/output-contract.js";
15
16
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport, type BuildDoctorValidationCorpusOptions, type DoctorValidationCorpusReport, type ValidationCorpusCaseDefinition, type ValidationCorpusCaseResult } from "./core/validation-corpus.js";
16
17
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
11
11
  export { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
12
12
  export { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestationJson, renderDoctorAttestationVerification, renderDoctorAttestationVerificationJson, verifyDoctorAttestation } from "./core/attestation.js";
13
13
  export { buildExternalValidationCorpusReport, ExternalCorpusManifestError, loadExternalCorpusManifest } from "./core/external-validation-corpus.js";
14
+ export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, loadCorpusMetricsManifest, reconcileCorpusMetricFindings, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
14
15
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
15
16
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
16
17
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
package/dist/run-cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
1
2
  import { runCheck } from "./index.js";
2
3
  import { writeRawDoctorConfig } from "./index.js";
3
4
  export interface CliIo {
@@ -18,5 +19,7 @@ export interface RunCliOptions {
18
19
  now?: () => Date;
19
20
  releaseAssetUploadImpl?: (args: string[]) => Promise<void>;
20
21
  resolveLatestVersion?: () => Promise<string>;
22
+ buildCorpusMetricsDiffReportImpl?: typeof buildCorpusQualityMetricsDiffReport;
23
+ buildCorpusMetricsReportImpl?: typeof buildCorpusQualityMetricsReport;
21
24
  }
22
25
  export declare function runCli(args: string[], io?: CliIo, options?: RunCliOptions): Promise<number>;
package/dist/run-cli.js CHANGED
@@ -21,6 +21,7 @@ import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
21
21
  import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
22
22
  import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
23
23
  import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
24
+ import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
24
25
  import { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
25
26
  import { buildDoctorRuntimePlan, evaluateRuntimeApproval, renderDoctorRuntimePlan, renderDoctorRuntimePlanMarkdown, renderDoctorRuntimePlanJson, runtimeApprovalPassed } from "./core/runtime-plan.js";
26
27
  import { buildDoctorRuntimePolicyReport, renderDoctorRuntimePolicy, renderDoctorRuntimePolicyJson } from "./core/runtime-policy.js";
@@ -99,7 +100,8 @@ function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
99
100
  return value;
100
101
  }
101
102
  function printUsage(io) {
102
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|runtime-plan <path> [--runtime --sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--runtime --sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime --sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--upload]|mcp <path>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime --sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
103
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--runtime --sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--runtime --sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime --sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--upload]|mcp <path>|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime --sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
104
+ io.writeStderr("Corpus quality regression: codex-plugin-doctor doctor corpus metrics diff --before <metrics.json> --after <metrics.json> [--fail-on-regression] [--json|--markdown] [--output <path>]");
103
105
  io.writeStderr("Suppression governance: codex-plugin-doctor suppress list <path> [--fail-on-expired] [--fail-on-invalid] [--warn-expiring-within-days <days>]\n codex-plugin-doctor suppress prune <path> [--apply] [--json]");
104
106
  io.writeStderr("Baseline gating: codex-plugin-doctor baseline create <path> --output <path> [--runtime]\n codex-plugin-doctor check <path> --baseline <path>");
105
107
  }
@@ -1416,6 +1418,156 @@ export async function runCli(args, io = defaultIo, options = {}) {
1416
1418
  return report.exitCode;
1417
1419
  }
1418
1420
  if (maybePath === "corpus") {
1421
+ if (remainingArgs[0] === "metrics") {
1422
+ const metricsArgs = remainingArgs.slice(1);
1423
+ if (metricsArgs[0] === "diff") {
1424
+ const diffArgs = metricsArgs.slice(1);
1425
+ const jsonOutput = diffArgs.includes("--json");
1426
+ const markdownOutput = diffArgs.includes("--markdown");
1427
+ const failOnRegression = diffArgs.includes("--fail-on-regression");
1428
+ const valueOptions = new Map();
1429
+ const valueFlags = new Set(["--before", "--after", "--output"]);
1430
+ const booleanFlags = new Set(["--json", "--markdown", "--fail-on-regression"]);
1431
+ for (let index = 0; index < diffArgs.length; index += 1) {
1432
+ const argument = diffArgs[index];
1433
+ if (booleanFlags.has(argument))
1434
+ continue;
1435
+ if (!valueFlags.has(argument)) {
1436
+ io.writeStderr(`Unknown corpus metrics diff argument: ${argument}.`);
1437
+ return 2;
1438
+ }
1439
+ const value = diffArgs[index + 1];
1440
+ if (!value || value.startsWith("--")) {
1441
+ io.writeStderr(`Missing path after ${argument}.`);
1442
+ return 2;
1443
+ }
1444
+ valueOptions.set(argument, value);
1445
+ index += 1;
1446
+ }
1447
+ const beforePath = valueOptions.get("--before");
1448
+ const afterPath = valueOptions.get("--after");
1449
+ const outputPath = valueOptions.get("--output") ?? null;
1450
+ if (!beforePath || !afterPath) {
1451
+ io.writeStderr("Corpus metrics diff requires --before <metrics.json> and --after <metrics.json>.");
1452
+ return 2;
1453
+ }
1454
+ if (jsonOutput && markdownOutput) {
1455
+ io.writeStderr("Use either --json or --markdown, not both.");
1456
+ return 2;
1457
+ }
1458
+ let report;
1459
+ try {
1460
+ const builder = options.buildCorpusMetricsDiffReportImpl ?? buildCorpusQualityMetricsDiffReport;
1461
+ report = await builder(beforePath, afterPath, { failOnRegression });
1462
+ }
1463
+ catch (error) {
1464
+ if (error instanceof CorpusMetricsDiffError) {
1465
+ io.writeStderr(error.message);
1466
+ return 2;
1467
+ }
1468
+ const message = error instanceof Error ? error.message : "Unknown error";
1469
+ io.writeStderr(`Corpus metrics diff failed: ${message}`);
1470
+ return 2;
1471
+ }
1472
+ const rendered = jsonOutput
1473
+ ? renderCorpusQualityMetricsDiffJson(report)
1474
+ : markdownOutput
1475
+ ? renderCorpusQualityMetricsDiffMarkdown(report)
1476
+ : renderCorpusQualityMetricsDiffText(report, { outputPath });
1477
+ if (outputPath)
1478
+ await writeFile(outputPath, rendered, "utf8");
1479
+ io.writeStdout(rendered);
1480
+ return report.exitCode;
1481
+ }
1482
+ const jsonOutput = metricsArgs.includes("--json");
1483
+ const markdownOutput = metricsArgs.includes("--markdown");
1484
+ const valueOptions = new Map();
1485
+ const knownValueFlags = new Set([
1486
+ "--manifest",
1487
+ "--output",
1488
+ "--min-precision",
1489
+ "--min-recall",
1490
+ "--max-false-positive-rate"
1491
+ ]);
1492
+ for (let index = 0; index < metricsArgs.length; index += 1) {
1493
+ const argument = metricsArgs[index];
1494
+ if (argument === "--json" || argument === "--markdown")
1495
+ continue;
1496
+ if (!knownValueFlags.has(argument)) {
1497
+ io.writeStderr(`Unknown corpus metrics argument: ${argument}.`);
1498
+ return 2;
1499
+ }
1500
+ const value = metricsArgs[index + 1];
1501
+ if (!value || value.startsWith("--")) {
1502
+ const label = argument === "--manifest"
1503
+ ? "path after --manifest"
1504
+ : argument === "--output"
1505
+ ? "path after --output"
1506
+ : `value after ${argument}`;
1507
+ io.writeStderr(`Missing ${label}.`);
1508
+ return 2;
1509
+ }
1510
+ valueOptions.set(argument, value);
1511
+ index += 1;
1512
+ }
1513
+ const manifestPath = valueOptions.get("--manifest");
1514
+ const outputPath = valueOptions.get("--output") ?? null;
1515
+ if (!manifestPath) {
1516
+ io.writeStderr("Corpus metrics requires --manifest <path>.");
1517
+ return 2;
1518
+ }
1519
+ if (jsonOutput && markdownOutput) {
1520
+ io.writeStderr("Use either --json or --markdown, not both.");
1521
+ return 2;
1522
+ }
1523
+ const thresholds = {};
1524
+ const thresholdFlags = [
1525
+ ["--min-precision", "minPrecision"],
1526
+ ["--min-recall", "minRecall"],
1527
+ ["--max-false-positive-rate", "maxFalsePositiveRate"]
1528
+ ];
1529
+ for (const [flag, key] of thresholdFlags) {
1530
+ const raw = valueOptions.get(flag);
1531
+ if (raw === undefined)
1532
+ continue;
1533
+ const value = Number(raw);
1534
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
1535
+ io.writeStderr(`${flag} must be between 0 and 1.`);
1536
+ return 2;
1537
+ }
1538
+ thresholds[key] = value;
1539
+ }
1540
+ let report;
1541
+ try {
1542
+ const builder = options.buildCorpusMetricsReportImpl ?? buildCorpusQualityMetricsReport;
1543
+ const buildOptions = {
1544
+ thresholds,
1545
+ environment: {
1546
+ env: terminalContext.env,
1547
+ platform: terminalContext.platform
1548
+ }
1549
+ };
1550
+ report = await builder(manifestPath, buildOptions);
1551
+ }
1552
+ catch (error) {
1553
+ if (error instanceof CorpusMetricsManifestError) {
1554
+ io.writeStderr(error.message);
1555
+ return 2;
1556
+ }
1557
+ const message = error instanceof Error ? error.message : "Unknown error";
1558
+ io.writeStderr(`Corpus metrics analysis failed: ${message}`);
1559
+ return 2;
1560
+ }
1561
+ const rendered = jsonOutput
1562
+ ? renderCorpusQualityMetricsJson(report)
1563
+ : markdownOutput
1564
+ ? renderCorpusQualityMetricsMarkdown(report)
1565
+ : renderCorpusQualityMetricsText(report, { outputPath });
1566
+ if (outputPath)
1567
+ await writeFile(outputPath, rendered, "utf8");
1568
+ io.writeStdout(rendered);
1569
+ return report.exitCode;
1570
+ }
1419
1571
  const jsonOutput = remainingArgs.includes("--json");
1420
1572
  const outputIndex = remainingArgs.indexOf("--output");
1421
1573
  const outputPath = outputIndex === -1 ? null : remainingArgs[outputIndex + 1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.47.0",
3
+ "version": "1.49.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",