codex-plugin-doctor 1.46.1 → 1.48.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.
@@ -0,0 +1,433 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { buildGenericMcpDoctor } from "../mcp/generic-mcp-doctor.js";
4
+ import { packageVersion } from "../version.js";
5
+ import { buildPackageFingerprint } from "./attestation.js";
6
+ import { readJsonFile } from "./read-json-file.js";
7
+ import { validatePlugin } from "./validate-plugin.js";
8
+ export class CorpusMetricsManifestError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "CorpusMetricsManifestError";
12
+ }
13
+ }
14
+ const manifestKeys = new Set(["schemaVersion", "thresholds", "targets"]);
15
+ const thresholdKeys = new Set(["minPrecision", "minRecall", "maxFalsePositiveRate"]);
16
+ const targetKeys = new Set([
17
+ "id", "profile", "sourceType", "disclosure", "path", "mode",
18
+ "contentDigest", "source", "reviews"
19
+ ]);
20
+ const sourceKeys = new Set(["repository", "revision"]);
21
+ const reviewKeys = new Set(["findingId", "fingerprint", "classification"]);
22
+ const profiles = new Set(["healthy", "broken", "edge-case"]);
23
+ const sourceTypes = new Set([
24
+ "public-package", "local-snapshot", "derived-fixture"
25
+ ]);
26
+ const modes = new Set(["codex-plugin", "generic-mcp"]);
27
+ const classifications = new Set([
28
+ "true_positive", "false_positive", "unclear"
29
+ ]);
30
+ const sha256Digest = /^sha256:[a-f0-9]{64}$/;
31
+ const fingerprintPattern = /^[a-f0-9]{64}$/;
32
+ const immutableRevision = /^[a-f0-9]{40}([a-f0-9]{24})?$/;
33
+ const publicSafeId = /^[a-z0-9][a-z0-9._-]{0,63}$/;
34
+ function isRecord(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function rejectUnknownKeys(value, allowed, label) {
38
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
39
+ if (unknown.length > 0) {
40
+ throw new CorpusMetricsManifestError(`${label} contains unsupported field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}.`);
41
+ }
42
+ }
43
+ function requireString(value, label) {
44
+ if (typeof value !== "string" || value.length === 0) {
45
+ throw new CorpusMetricsManifestError(`${label} must be a non-empty string.`);
46
+ }
47
+ return value;
48
+ }
49
+ function parseThresholds(value) {
50
+ if (value === undefined)
51
+ return undefined;
52
+ if (!isRecord(value))
53
+ throw new CorpusMetricsManifestError("Metrics thresholds must be an object.");
54
+ rejectUnknownKeys(value, thresholdKeys, "Metrics thresholds");
55
+ const result = {};
56
+ for (const key of thresholdKeys) {
57
+ const candidate = value[key];
58
+ if (candidate === undefined)
59
+ continue;
60
+ if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate < 0 || candidate > 1) {
61
+ throw new CorpusMetricsManifestError(`Metrics threshold ${key} must be between 0 and 1.`);
62
+ }
63
+ result[key] = candidate;
64
+ }
65
+ return result;
66
+ }
67
+ function parseSource(value, targetId) {
68
+ if (value === undefined)
69
+ return undefined;
70
+ if (!isRecord(value))
71
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source must be an object.`);
72
+ rejectUnknownKeys(value, sourceKeys, `Metrics target ${targetId} source`);
73
+ const repository = requireString(value.repository, `Metrics target ${targetId} source repository`);
74
+ const revision = requireString(value.revision, `Metrics target ${targetId} source revision`);
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(repository);
78
+ }
79
+ catch {
80
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source repository must use HTTPS.`);
81
+ }
82
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) {
83
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source repository must use HTTPS.`);
84
+ }
85
+ if (!immutableRevision.test(revision)) {
86
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} source revision must be immutable.`);
87
+ }
88
+ return { repository, revision };
89
+ }
90
+ function parseReview(value, targetId, index) {
91
+ if (!isRecord(value))
92
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} review ${index} must be an object.`);
93
+ rejectUnknownKeys(value, reviewKeys, `Metrics target ${targetId} review ${index}`);
94
+ const findingId = requireString(value.findingId, `Metrics target ${targetId} review ${index} findingId`);
95
+ const fingerprint = requireString(value.fingerprint, `Metrics target ${targetId} review ${index} fingerprint`);
96
+ if (!fingerprintPattern.test(fingerprint)) {
97
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} review ${index} fingerprint must be a sha256 fingerprint.`);
98
+ }
99
+ if (typeof value.classification !== "string" ||
100
+ !classifications.has(value.classification)) {
101
+ throw new CorpusMetricsManifestError(`Metrics target ${targetId} review ${index} has an unsupported classification.`);
102
+ }
103
+ return { findingId, fingerprint, classification: value.classification };
104
+ }
105
+ function resolveContainedTarget(manifestDirectory, targetPath) {
106
+ if (path.posix.isAbsolute(targetPath) || path.win32.isAbsolute(targetPath)) {
107
+ throw new CorpusMetricsManifestError("Metrics target path must be relative to the manifest file.");
108
+ }
109
+ const root = path.resolve(manifestDirectory);
110
+ const resolved = path.resolve(root, targetPath);
111
+ const relative = path.relative(root, resolved);
112
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
113
+ throw new CorpusMetricsManifestError("Metrics target path must remain beneath the manifest directory.");
114
+ }
115
+ return resolved;
116
+ }
117
+ async function parseTarget(value, manifestDirectory, index) {
118
+ if (!isRecord(value))
119
+ throw new CorpusMetricsManifestError(`Metrics target ${index} must be an object.`);
120
+ rejectUnknownKeys(value, targetKeys, `Metrics target ${index}`);
121
+ const id = requireString(value.id, `Metrics target ${index} id`);
122
+ if (!publicSafeId.test(id)) {
123
+ throw new CorpusMetricsManifestError(`Metrics target ${index} id must be public-safe.`);
124
+ }
125
+ const targetPath = requireString(value.path, `Metrics target ${id} path`);
126
+ if (typeof value.profile !== "string" || !profiles.has(value.profile)) {
127
+ throw new CorpusMetricsManifestError(`Metrics target ${id} has an unsupported profile.`);
128
+ }
129
+ if (typeof value.sourceType !== "string" || !sourceTypes.has(value.sourceType)) {
130
+ throw new CorpusMetricsManifestError(`Metrics target ${id} has an unsupported sourceType.`);
131
+ }
132
+ if (value.disclosure !== "anonymized") {
133
+ throw new CorpusMetricsManifestError(`Metrics target ${id} disclosure must be anonymized.`);
134
+ }
135
+ if (typeof value.mode !== "string" || !modes.has(value.mode)) {
136
+ throw new CorpusMetricsManifestError(`Metrics target ${id} has an unsupported mode.`);
137
+ }
138
+ if (typeof value.contentDigest !== "string" || !sha256Digest.test(value.contentDigest)) {
139
+ throw new CorpusMetricsManifestError(`Metrics target ${id} contentDigest must be a sha256 digest.`);
140
+ }
141
+ if (!Array.isArray(value.reviews)) {
142
+ throw new CorpusMetricsManifestError(`Metrics target ${id} reviews must be an array.`);
143
+ }
144
+ const reviews = value.reviews.map((review, reviewIndex) => parseReview(review, id, reviewIndex));
145
+ const reviewKeysSeen = new Set();
146
+ for (const review of reviews) {
147
+ const key = findingKey(review.findingId, review.fingerprint);
148
+ if (reviewKeysSeen.has(key)) {
149
+ throw new CorpusMetricsManifestError(`Metrics target ${id} contains a duplicate finding review.`);
150
+ }
151
+ reviewKeysSeen.add(key);
152
+ }
153
+ const resolvedPath = resolveContainedTarget(manifestDirectory, targetPath);
154
+ try {
155
+ if (!(await stat(resolvedPath)).isDirectory())
156
+ throw new Error("not a directory");
157
+ }
158
+ catch {
159
+ throw new CorpusMetricsManifestError(`Metrics target ${id} directory does not exist.`);
160
+ }
161
+ const source = parseSource(value.source, id);
162
+ return {
163
+ id,
164
+ profile: value.profile,
165
+ sourceType: value.sourceType,
166
+ disclosure: "anonymized",
167
+ path: targetPath,
168
+ mode: value.mode,
169
+ contentDigest: value.contentDigest,
170
+ ...(source ? { source } : {}),
171
+ reviews,
172
+ resolvedPath
173
+ };
174
+ }
175
+ export async function loadCorpusMetricsManifest(manifestPath) {
176
+ let value;
177
+ try {
178
+ value = await readJsonFile(path.resolve(manifestPath));
179
+ }
180
+ catch {
181
+ throw new CorpusMetricsManifestError("Metrics manifest could not be read as JSON.");
182
+ }
183
+ if (!isRecord(value))
184
+ throw new CorpusMetricsManifestError("Metrics manifest must be an object.");
185
+ rejectUnknownKeys(value, manifestKeys, "Metrics manifest");
186
+ if (value.schemaVersion !== "1.0.0") {
187
+ throw new CorpusMetricsManifestError("Unsupported metrics manifest schemaVersion.");
188
+ }
189
+ if (!Array.isArray(value.targets) || value.targets.length === 0) {
190
+ throw new CorpusMetricsManifestError("Metrics manifest targets must be a non-empty array.");
191
+ }
192
+ const manifestDirectory = path.dirname(path.resolve(manifestPath));
193
+ const targets = await Promise.all(value.targets.map((target, index) => parseTarget(target, manifestDirectory, index)));
194
+ const ids = new Set();
195
+ for (const target of targets) {
196
+ if (ids.has(target.id))
197
+ throw new CorpusMetricsManifestError(`Metrics target id ${target.id} is duplicated.`);
198
+ ids.add(target.id);
199
+ }
200
+ const thresholds = parseThresholds(value.thresholds);
201
+ return { schemaVersion: "1.0.0", ...(thresholds ? { thresholds } : {}), targets };
202
+ }
203
+ function findingKey(findingId, fingerprint) {
204
+ return `${findingId}:${fingerprint}`;
205
+ }
206
+ function roundMetric(value) {
207
+ return value === null ? null : Math.round(value * 1_000_000) / 1_000_000;
208
+ }
209
+ function calculateRawMetrics(counts) {
210
+ const precisionDenominator = counts.truePositives + counts.falsePositives;
211
+ const recallDenominator = counts.truePositives + counts.falseNegatives;
212
+ return {
213
+ precision: precisionDenominator === 0 ? null : counts.truePositives / precisionDenominator,
214
+ recall: recallDenominator === 0 ? null : counts.truePositives / recallDenominator,
215
+ falsePositiveRate: precisionDenominator === 0 ? null : counts.falsePositives / precisionDenominator
216
+ };
217
+ }
218
+ function calculateMetrics(counts) {
219
+ const raw = calculateRawMetrics(counts);
220
+ return {
221
+ precision: roundMetric(raw.precision),
222
+ recall: roundMetric(raw.recall),
223
+ falsePositiveRate: roundMetric(raw.falsePositiveRate)
224
+ };
225
+ }
226
+ export function reconcileCorpusMetricFindings(reviews, actualFindings) {
227
+ const reviewsByKey = new Map(reviews.map((review) => [findingKey(review.findingId, review.fingerprint), review]));
228
+ const actualKeys = new Set(actualFindings.map((finding) => findingKey(finding.findingId, finding.fingerprint)));
229
+ const outcomes = actualFindings.map((finding) => ({
230
+ ...finding,
231
+ classification: reviewsByKey.get(findingKey(finding.findingId, finding.fingerprint))?.classification ?? "unreviewed",
232
+ emitted: true
233
+ }));
234
+ for (const review of reviews) {
235
+ if (!actualKeys.has(findingKey(review.findingId, review.fingerprint))) {
236
+ outcomes.push({ ...review, emitted: false });
237
+ }
238
+ }
239
+ outcomes.sort((left, right) => findingKey(left.findingId, left.fingerprint)
240
+ .localeCompare(findingKey(right.findingId, right.fingerprint)));
241
+ const counts = {
242
+ truePositives: outcomes.filter((item) => item.emitted && item.classification === "true_positive").length,
243
+ falsePositives: outcomes.filter((item) => item.emitted && item.classification === "false_positive").length,
244
+ falseNegatives: outcomes.filter((item) => !item.emitted && item.classification === "true_positive").length,
245
+ resolvedFalsePositives: outcomes.filter((item) => !item.emitted && item.classification === "false_positive").length,
246
+ unreviewed: outcomes.filter((item) => item.emitted && item.classification === "unreviewed").length,
247
+ unclear: outcomes.filter((item) => item.classification === "unclear").length
248
+ };
249
+ return {
250
+ counts,
251
+ metrics: calculateMetrics(counts),
252
+ complete: counts.unreviewed === 0 && counts.unclear === 0,
253
+ outcomes
254
+ };
255
+ }
256
+ function addCounts(left, right) {
257
+ return {
258
+ truePositives: left.truePositives + right.truePositives,
259
+ falsePositives: left.falsePositives + right.falsePositives,
260
+ falseNegatives: left.falseNegatives + right.falseNegatives,
261
+ resolvedFalsePositives: left.resolvedFalsePositives + right.resolvedFalsePositives,
262
+ unreviewed: left.unreviewed + right.unreviewed,
263
+ unclear: left.unclear + right.unclear
264
+ };
265
+ }
266
+ async function defaultAnalyzeTarget(targetPath, mode, environment) {
267
+ const report = mode === "generic-mcp"
268
+ ? await buildGenericMcpDoctor(targetPath, environment)
269
+ : await validatePlugin(targetPath);
270
+ return {
271
+ findings: report.findings.map((finding) => {
272
+ if (!finding.fingerprint) {
273
+ throw new CorpusMetricsManifestError("Metrics analysis produced a finding without a fingerprint.");
274
+ }
275
+ return { findingId: finding.id, fingerprint: finding.fingerprint };
276
+ })
277
+ };
278
+ }
279
+ function buildThresholdChecks(metrics, thresholds) {
280
+ const definitions = [
281
+ ["minPrecision", "precision", ">="],
282
+ ["minRecall", "recall", ">="],
283
+ ["maxFalsePositiveRate", "falsePositiveRate", "<="]
284
+ ];
285
+ return definitions.flatMap(([thresholdKey, metric, operator]) => {
286
+ const threshold = thresholds[thresholdKey];
287
+ if (threshold === undefined)
288
+ return [];
289
+ const actual = metrics[metric];
290
+ const passed = actual !== null && (operator === ">=" ? actual >= threshold : actual <= threshold);
291
+ return [{ metric, operator, threshold, actual, passed }];
292
+ });
293
+ }
294
+ export async function buildCorpusQualityMetricsReport(manifestPath, options = {}) {
295
+ const manifest = await loadCorpusMetricsManifest(manifestPath);
296
+ const thresholds = { ...(manifest.thresholds ?? {}), ...(options.thresholds ?? {}) };
297
+ const analyzeTarget = options.analyzeTarget ?? ((targetPath, mode) => defaultAnalyzeTarget(targetPath, mode, options.environment));
298
+ const fingerprintBuilder = options.buildFingerprint ?? buildPackageFingerprint;
299
+ const targets = [];
300
+ for (const target of manifest.targets) {
301
+ const [analysis, fingerprint] = await Promise.all([
302
+ analyzeTarget(target.resolvedPath, target.mode),
303
+ fingerprintBuilder(target.resolvedPath)
304
+ ]);
305
+ const reconciliation = reconcileCorpusMetricFindings(target.reviews, analysis.findings);
306
+ const digestMatched = fingerprint.digest === target.contentDigest;
307
+ targets.push({
308
+ id: target.id,
309
+ profile: target.profile,
310
+ sourceType: target.sourceType,
311
+ disclosure: target.disclosure,
312
+ mode: target.mode,
313
+ ...(target.source ? { source: target.source } : {}),
314
+ digestMatched,
315
+ counts: reconciliation.counts,
316
+ metrics: reconciliation.metrics,
317
+ complete: reconciliation.complete && digestMatched,
318
+ outcomes: reconciliation.outcomes
319
+ });
320
+ }
321
+ const emptyCounts = {
322
+ truePositives: 0, falsePositives: 0, falseNegatives: 0,
323
+ resolvedFalsePositives: 0, unreviewed: 0, unclear: 0
324
+ };
325
+ const counts = targets.reduce((total, target) => addCounts(total, target.counts), emptyCounts);
326
+ const metrics = calculateMetrics(counts);
327
+ const rawMetrics = calculateRawMetrics(counts);
328
+ const completeTargets = targets.filter((target) => target.complete).length;
329
+ const thresholdChecks = buildThresholdChecks(rawMetrics, thresholds).map((check) => ({
330
+ ...check,
331
+ actual: metrics[check.metric]
332
+ }));
333
+ const incomplete = completeTargets !== targets.length;
334
+ const failedThreshold = thresholdChecks.some((check) => !check.passed);
335
+ const status = incomplete ? "incomplete" : failedThreshold ? "fail" : "pass";
336
+ const exitCode = incomplete ? 2 : failedThreshold ? 1 : 0;
337
+ return {
338
+ schemaVersion: "1.0.0",
339
+ kind: "doctor.validation.corpus.metrics",
340
+ generatedAt: new Date().toISOString(),
341
+ version: packageVersion,
342
+ status,
343
+ exitCode,
344
+ summary: {
345
+ targetCount: targets.length,
346
+ completeTargets,
347
+ incompleteTargets: targets.length - completeTargets,
348
+ ...counts,
349
+ ...metrics
350
+ },
351
+ thresholds,
352
+ thresholdChecks,
353
+ targets
354
+ };
355
+ }
356
+ function formatMetric(value) {
357
+ return value === null ? "N/A" : `${(value * 100).toFixed(2)}%`;
358
+ }
359
+ function metricLabel(metric) {
360
+ if (metric === "falsePositiveRate")
361
+ return "False-positive rate";
362
+ return metric[0].toUpperCase() + metric.slice(1);
363
+ }
364
+ export function renderCorpusQualityMetricsJson(report) {
365
+ return JSON.stringify(report, null, 2);
366
+ }
367
+ export function renderCorpusQualityMetricsText(report, options = {}) {
368
+ const lines = [
369
+ "Doctor Corpus Quality Metrics",
370
+ "=============================",
371
+ `Version: ${report.version}`,
372
+ `Status: ${report.status.toUpperCase()}`,
373
+ `Targets: ${report.summary.targetCount}`,
374
+ `Precision: ${formatMetric(report.summary.precision)}`,
375
+ `Recall: ${formatMetric(report.summary.recall)}`,
376
+ `False-positive rate: ${formatMetric(report.summary.falsePositiveRate)}`,
377
+ `TP: ${report.summary.truePositives} FP: ${report.summary.falsePositives} FN: ${report.summary.falseNegatives}`
378
+ ];
379
+ if (options.outputPath)
380
+ lines.push(`Output: ${options.outputPath}`);
381
+ const failed = report.thresholdChecks.filter((check) => !check.passed);
382
+ lines.push("", "Failed thresholds", "-----------------");
383
+ if (failed.length === 0) {
384
+ lines.push("None.");
385
+ }
386
+ else {
387
+ for (const check of failed) {
388
+ lines.push(`${metricLabel(check.metric)}: ${formatMetric(check.actual)} ${check.operator} ${formatMetric(check.threshold)} required`);
389
+ }
390
+ }
391
+ lines.push("", "Targets", "-------");
392
+ for (const target of report.targets) {
393
+ lines.push(`${target.id}: ${target.complete ? "COMPLETE" : "INCOMPLETE"} ` +
394
+ `(precision ${formatMetric(target.metrics.precision)}, recall ${formatMetric(target.metrics.recall)})`);
395
+ }
396
+ return lines.join("\n");
397
+ }
398
+ export function renderCorpusQualityMetricsMarkdown(report) {
399
+ const lines = [
400
+ "# Doctor Corpus Quality Metrics",
401
+ "",
402
+ `- Version: ${report.version}`,
403
+ `- Status: ${report.status.toUpperCase()}`,
404
+ `- Targets: ${report.summary.targetCount}`,
405
+ "",
406
+ "| Metric | Value |",
407
+ "| --- | ---: |",
408
+ `| Precision | ${formatMetric(report.summary.precision)} |`,
409
+ `| Recall | ${formatMetric(report.summary.recall)} |`,
410
+ `| False-positive rate | ${formatMetric(report.summary.falsePositiveRate)} |`,
411
+ `| True positives | ${report.summary.truePositives} |`,
412
+ `| False positives | ${report.summary.falsePositives} |`,
413
+ `| False negatives | ${report.summary.falseNegatives} |`,
414
+ "",
415
+ "## Failed Thresholds",
416
+ ""
417
+ ];
418
+ const failed = report.thresholdChecks.filter((check) => !check.passed);
419
+ if (failed.length === 0) {
420
+ lines.push("None.");
421
+ }
422
+ else {
423
+ for (const check of failed) {
424
+ lines.push(`- ${metricLabel(check.metric)}: ${formatMetric(check.actual)} ${check.operator} ${formatMetric(check.threshold)} required`);
425
+ }
426
+ }
427
+ lines.push("", "## Targets", "", "| Target | Review | Precision | Recall |", "| --- | --- | ---: | ---: |");
428
+ for (const target of report.targets) {
429
+ lines.push(`| ${target.id} | ${target.complete ? "complete" : "incomplete"} | ` +
430
+ `${formatMetric(target.metrics.precision)} | ${formatMetric(target.metrics.recall)} |`);
431
+ }
432
+ return lines.join("\n");
433
+ }
@@ -0,0 +1,88 @@
1
+ export type ExternalCorpusProfile = "healthy" | "broken" | "edge-case";
2
+ export type ExternalCorpusSourceType = "public-package" | "local-snapshot" | "derived-fixture";
3
+ export type ExternalCorpusMode = "codex-plugin" | "generic-mcp";
4
+ export type FindingReviewClassification = "true_positive" | "false_positive" | "unclear";
5
+ export interface ExternalCorpusFindingReview {
6
+ findingId: string;
7
+ fingerprint: string;
8
+ classification: FindingReviewClassification;
9
+ }
10
+ export interface ExternalCorpusTarget {
11
+ id: string;
12
+ profile: ExternalCorpusProfile;
13
+ sourceType: ExternalCorpusSourceType;
14
+ disclosure: "anonymized";
15
+ path: string;
16
+ mode: ExternalCorpusMode;
17
+ contentDigest: string;
18
+ expectedStatus: "pass" | "warn" | "fail";
19
+ reviews: ExternalCorpusFindingReview[];
20
+ }
21
+ export interface ExternalCorpusManifest {
22
+ schemaVersion: "1.0.0";
23
+ targets: ExternalCorpusTarget[];
24
+ }
25
+ export interface LoadedExternalCorpusTarget extends ExternalCorpusTarget {
26
+ resolvedPath: string;
27
+ }
28
+ export interface LoadedExternalCorpusManifest extends ExternalCorpusManifest {
29
+ targets: LoadedExternalCorpusTarget[];
30
+ }
31
+ export interface ExternalCorpusReportedFinding {
32
+ findingId: string;
33
+ fingerprint: string;
34
+ classification: FindingReviewClassification | "unreviewed";
35
+ }
36
+ export interface ExternalCorpusCaseResult {
37
+ id: string;
38
+ profile: ExternalCorpusProfile;
39
+ sourceType: ExternalCorpusSourceType;
40
+ disclosure: "anonymized";
41
+ mode: ExternalCorpusMode;
42
+ expected: {
43
+ status: ExternalCorpusTarget["expectedStatus"];
44
+ contentDigest: string;
45
+ findings: ExternalCorpusFindingReview[];
46
+ };
47
+ actual: {
48
+ status: ExternalCorpusTarget["expectedStatus"];
49
+ contentDigest: string;
50
+ findings: ExternalCorpusReportedFinding[];
51
+ };
52
+ digestMatched: boolean;
53
+ classificationCounts: ExternalCorpusClassificationCounts;
54
+ expectationMatched: boolean;
55
+ }
56
+ export interface ExternalCorpusClassificationCounts {
57
+ truePositive: number;
58
+ falsePositive: number;
59
+ unclear: number;
60
+ missingExpectedFinding: number;
61
+ }
62
+ export interface ExternalValidationCorpusReport {
63
+ schemaVersion: "1.0.0";
64
+ kind: "doctor.validation.corpus";
65
+ corpusType: "external";
66
+ generatedAt: string;
67
+ version: string;
68
+ summary: {
69
+ status: "pass" | "fail";
70
+ caseCount: number;
71
+ passedExpectations: number;
72
+ failedExpectations: number;
73
+ runtimeCases: 0;
74
+ classificationCounts: ExternalCorpusClassificationCounts;
75
+ };
76
+ cases: ExternalCorpusCaseResult[];
77
+ }
78
+ export interface BuildExternalValidationCorpusOptions {
79
+ environment?: {
80
+ env?: NodeJS.ProcessEnv;
81
+ platform?: NodeJS.Platform;
82
+ };
83
+ }
84
+ export declare class ExternalCorpusManifestError extends Error {
85
+ constructor(message: string);
86
+ }
87
+ export declare function loadExternalCorpusManifest(manifestPath: string): Promise<LoadedExternalCorpusManifest>;
88
+ export declare function buildExternalValidationCorpusReport(manifestPath: string, options?: BuildExternalValidationCorpusOptions): Promise<ExternalValidationCorpusReport>;