codex-plugin-doctor 1.58.0 → 1.59.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,301 @@
1
+ import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isAlias, isNode, parseDocument, visit } from "yaml";
4
+ const maxSkillBytes = 1024 * 1024;
5
+ const maxAgentBytes = 256 * 1024;
6
+ const maxAggregateMetadataBytes = 16 * 1024 * 1024;
7
+ const maxDirectoryEntries = 256;
8
+ const maxSkillDirectories = 100;
9
+ const unsupportedText = /[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
10
+ const interfaceKeys = new Set(["display_name", "short_description", "icon_small", "icon_large", "brand_color", "default_prompt"]);
11
+ const policyKeys = new Set(["products", "allow_implicit_invocation"]);
12
+ const dependencyKeys = new Set(["tools"]);
13
+ const toolDescriptorKeys = new Set(["type", "value", "description", "transport", "url"]);
14
+ const agentKeys = new Set(["interface", "policy", "dependencies"]);
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function isWithin(rootPath, candidatePath) {
19
+ const relative = path.relative(rootPath, candidatePath);
20
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
21
+ }
22
+ function packagePath(rootPath, targetPath) {
23
+ return path.relative(rootPath, targetPath).split(path.sep).join("/");
24
+ }
25
+ function finding(id, message, evidence) {
26
+ return evidence === undefined
27
+ ? { id, severity: "fail", message }
28
+ : { id, severity: "fail", message, evidence };
29
+ }
30
+ function supportedText(value, limit = Number.MAX_SAFE_INTEGER) {
31
+ return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !unsupportedText.test(value);
32
+ }
33
+ function parseSafeYaml(source) {
34
+ const document = parseDocument(source, { schema: "core", uniqueKeys: true, strict: true, prettyErrors: false });
35
+ let alias = false;
36
+ let nonCoreTag = false;
37
+ visit(document, (_key, node) => {
38
+ if (isAlias(node))
39
+ alias = true;
40
+ if (isNode(node) && node.tag !== undefined && !node.tag.startsWith("tag:yaml.org,2002:"))
41
+ nonCoreTag = true;
42
+ });
43
+ if (document.errors.length > 0 || alias || nonCoreTag)
44
+ return { error: "yaml" };
45
+ try {
46
+ const value = document.toJS({ maxAliasCount: 0 });
47
+ return isRecord(value) ? { value } : { error: "shape" };
48
+ }
49
+ catch {
50
+ return { error: "yaml" };
51
+ }
52
+ }
53
+ function splitSkillFile(source) {
54
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source);
55
+ if (!match || match[2].trim().length === 0)
56
+ return null;
57
+ return { frontmatter: match[1], body: match[2] };
58
+ }
59
+ async function safeDirectory(rootPath, skillsPath) {
60
+ try {
61
+ const [canonicalRoot, canonicalSkills, details] = await Promise.all([realpath(rootPath), realpath(skillsPath), stat(skillsPath)]);
62
+ return details.isDirectory() && isWithin(canonicalRoot, canonicalSkills) ? { canonicalRoot, canonicalSkills } : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ function budgetFinding(bytes) {
69
+ return finding("plugin.submission.skill.budget_exceeded", "Skill metadata exceeds the aggregate submission preflight size limit.", {
70
+ count: bytes, limit: maxAggregateMetadataBytes
71
+ });
72
+ }
73
+ async function readSafeUtf8(filePath, maximum, budget) {
74
+ try {
75
+ const details = await stat(filePath);
76
+ if (!details.isFile() || details.size > maximum)
77
+ return { kind: "invalid" };
78
+ const nextBytes = budget.bytes + details.size;
79
+ if (nextBytes > maxAggregateMetadataBytes)
80
+ return { kind: "budget", nextBytes };
81
+ budget.bytes = nextBytes;
82
+ return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(await readFile(filePath)) };
83
+ }
84
+ catch {
85
+ return { kind: "invalid" };
86
+ }
87
+ }
88
+ function rejectUnknownKeys(value, allowed) {
89
+ return Object.keys(value).some((key) => !allowed.has(key));
90
+ }
91
+ function isToolDescriptor(value) {
92
+ return isRecord(value) && !rejectUnknownKeys(value, toolDescriptorKeys)
93
+ && (value.type === "mcp" || value.type === "cli")
94
+ && (value.type !== "cli" || (value.transport === undefined && value.url === undefined))
95
+ && supportedText(value.value)
96
+ && (value.description === undefined || supportedText(value.description))
97
+ && (value.transport === undefined || supportedText(value.transport))
98
+ && (value.url === undefined || supportedText(value.url));
99
+ }
100
+ async function validateIconPath(rootPath, canonicalRoot, skillRoot, skillPath, field, value) {
101
+ if (typeof value !== "string" || value.trim() !== value || !supportedText(value)
102
+ || path.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value)) {
103
+ return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
104
+ }
105
+ const iconPath = path.resolve(skillRoot, value);
106
+ if (!isWithin(rootPath, iconPath)) {
107
+ return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
108
+ }
109
+ try {
110
+ const [canonicalIcon, details] = await Promise.all([realpath(iconPath), stat(iconPath)]);
111
+ if (!details.isFile() || !isWithin(canonicalRoot, canonicalIcon))
112
+ throw new Error("unsafe icon");
113
+ return null;
114
+ }
115
+ catch {
116
+ return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
117
+ }
118
+ }
119
+ async function validateAgentFile(rootPath, skillRoot, skillPath, budget) {
120
+ const agentPath = path.join(skillRoot, "agents", "openai.yaml");
121
+ let agentDetails;
122
+ try {
123
+ agentDetails = await lstat(agentPath);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ if (agentDetails.isSymbolicLink()) {
129
+ return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata must not be a symbolic link.", { path: skillPath })];
130
+ }
131
+ if (!agentDetails.isFile()) {
132
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a regular file.", { path: packagePath(rootPath, agentPath) })];
133
+ }
134
+ let canonicalRoot;
135
+ let canonicalSkill;
136
+ let canonicalAgent;
137
+ try {
138
+ [canonicalRoot, canonicalSkill, canonicalAgent] = await Promise.all([realpath(rootPath), realpath(skillRoot), realpath(agentPath)]);
139
+ }
140
+ catch {
141
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a readable regular file.", { path: packagePath(rootPath, agentPath) })];
142
+ }
143
+ if (!isWithin(canonicalRoot, canonicalAgent) || !isWithin(canonicalSkill, canonicalAgent)) {
144
+ return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })];
145
+ }
146
+ const source = await readSafeUtf8(agentPath, maxAgentBytes, budget);
147
+ if (source.kind === "budget") {
148
+ return [budgetFinding(source.nextBytes)];
149
+ }
150
+ if (source.kind === "invalid") {
151
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a bounded UTF-8 regular file.", { path: packagePath(rootPath, agentPath), limit: maxAgentBytes })];
152
+ }
153
+ const parsed = parseSafeYaml(source.source);
154
+ if ("error" in parsed) {
155
+ return [finding(parsed.error === "yaml" ? "plugin.submission.skill.agent.invalid_yaml" : "plugin.submission.skill.agent.invalid_shape", "Optional agent metadata must be a safe YAML mapping.", { path: packagePath(rootPath, agentPath) })];
156
+ }
157
+ const metadata = parsed.value;
158
+ if (rejectUnknownKeys(metadata, agentKeys) || !isRecord(metadata.interface) || rejectUnknownKeys(metadata.interface, interfaceKeys)
159
+ || !supportedText(metadata.interface.display_name) || !supportedText(metadata.interface.short_description)) {
160
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an unsupported shape.", { path: packagePath(rootPath, agentPath) })];
161
+ }
162
+ if (metadata.interface.brand_color !== undefined && (typeof metadata.interface.brand_color !== "string" || !/^#[0-9A-Fa-f]{6}$/u.test(metadata.interface.brand_color))) {
163
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid brand color.", { path: packagePath(rootPath, agentPath), field: "brand_color" })];
164
+ }
165
+ if (metadata.interface.default_prompt !== undefined && !supportedText(metadata.interface.default_prompt)) {
166
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid default prompt.", { path: packagePath(rootPath, agentPath), field: "default_prompt" })];
167
+ }
168
+ if (metadata.policy !== undefined && (!isRecord(metadata.policy) || rejectUnknownKeys(metadata.policy, policyKeys)
169
+ || (metadata.policy.products !== undefined && (!Array.isArray(metadata.policy.products) || metadata.policy.products.length === 0
170
+ || new Set(metadata.policy.products).size !== metadata.policy.products.length
171
+ || metadata.policy.products.some((product) => product !== "CHAT" && product !== "CODEX")))
172
+ || (metadata.policy.allow_implicit_invocation !== undefined && typeof metadata.policy.allow_implicit_invocation !== "boolean"))) {
173
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent policy has an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "policy" })];
174
+ }
175
+ if (metadata.dependencies !== undefined && (!isRecord(metadata.dependencies) || rejectUnknownKeys(metadata.dependencies, dependencyKeys)
176
+ || !Array.isArray(metadata.dependencies.tools) || metadata.dependencies.tools.length === 0
177
+ || metadata.dependencies.tools.some((tool) => !isToolDescriptor(tool)))) {
178
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent dependencies have an unsupported shape.", { path: packagePath(rootPath, agentPath), field: "dependencies" })];
179
+ }
180
+ for (const field of ["icon_small", "icon_large"]) {
181
+ const value = metadata.interface[field];
182
+ if (value === undefined)
183
+ continue;
184
+ const iconFinding = await validateIconPath(rootPath, canonicalRoot, skillRoot, skillPath, field, value);
185
+ if (iconFinding)
186
+ return [iconFinding];
187
+ }
188
+ return [];
189
+ }
190
+ export async function validateSubmissionSkillMetadata(discoveredPackage, targetType) {
191
+ const { manifest, rootPath } = discoveredPackage;
192
+ if (manifest.skills === undefined) {
193
+ return targetType === "skills-only"
194
+ ? { findings: [finding("plugin.submission.skill.required", "Skills-only submissions require a valid skill.")], skillCount: 0 }
195
+ : { findings: [], skillCount: 0 };
196
+ }
197
+ if (manifest.skills !== "./skills" && manifest.skills !== "./skills/") {
198
+ return { findings: [finding("plugin.submission.skill.invalid_manifest", "Skills must be declared as the root ./skills directory.", { field: "skills" })], skillCount: 0 };
199
+ }
200
+ const skillsPath = path.resolve(rootPath, manifest.skills);
201
+ const safe = await safeDirectory(rootPath, skillsPath);
202
+ if (!safe) {
203
+ return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory must be canonically contained in the package.", { path: "skills" })], skillCount: 0 };
204
+ }
205
+ const findings = [];
206
+ const identities = new Set();
207
+ let skillCount = 0;
208
+ const budget = { bytes: 0 };
209
+ let entries;
210
+ try {
211
+ entries = await opendir(skillsPath);
212
+ }
213
+ catch {
214
+ return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory cannot be inspected safely.", { path: "skills" })], skillCount: 0 };
215
+ }
216
+ let entryCount = 0;
217
+ let skillDirectoryCount = 0;
218
+ for await (const entry of entries) {
219
+ entryCount += 1;
220
+ if (entryCount > maxDirectoryEntries) {
221
+ findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight entry limit.", { count: entryCount, limit: maxDirectoryEntries }));
222
+ break;
223
+ }
224
+ if (entry.name.startsWith(".") || !entry.isDirectory())
225
+ continue;
226
+ skillDirectoryCount += 1;
227
+ if (skillDirectoryCount > maxSkillDirectories) {
228
+ findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight skill limit.", { count: skillDirectoryCount, limit: maxSkillDirectories }));
229
+ break;
230
+ }
231
+ const skillRoot = path.join(skillsPath, entry.name);
232
+ const skillFile = path.join(skillRoot, "SKILL.md");
233
+ const relativeSkillPath = packagePath(rootPath, skillFile);
234
+ let canonicalSkill;
235
+ try {
236
+ canonicalSkill = await realpath(skillRoot);
237
+ if (!isWithin(safe.canonicalSkills, canonicalSkill))
238
+ throw new Error("unsafe skill root");
239
+ }
240
+ catch {
241
+ findings.push(finding("plugin.submission.skill.invalid_path", "Skill directory resolves outside the declared skills directory.", { path: packagePath(rootPath, skillRoot) }));
242
+ continue;
243
+ }
244
+ try {
245
+ const details = await lstat(skillFile);
246
+ if (details.isSymbolicLink()) {
247
+ findings.push(finding("plugin.submission.skill.invalid_path", "Skill entrypoint must not be a symbolic link.", { path: relativeSkillPath }));
248
+ continue;
249
+ }
250
+ if (!details.isFile() || !isWithin(canonicalSkill, await realpath(skillFile))) {
251
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: relativeSkillPath }));
252
+ continue;
253
+ }
254
+ }
255
+ catch {
256
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: relativeSkillPath }));
257
+ continue;
258
+ }
259
+ const source = await readSafeUtf8(skillFile, maxSkillBytes, budget);
260
+ if (source.kind === "budget") {
261
+ findings.push(budgetFinding(source.nextBytes));
262
+ break;
263
+ }
264
+ if (source.kind === "invalid") {
265
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a bounded UTF-8 regular file.", { path: relativeSkillPath, limit: maxSkillBytes }));
266
+ continue;
267
+ }
268
+ const split = splitSkillFile(source.source);
269
+ if (!split) {
270
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint requires delimited frontmatter and a nonempty body.", { path: relativeSkillPath }));
271
+ continue;
272
+ }
273
+ const parsed = parseSafeYaml(split.frontmatter);
274
+ if ("error" in parsed) {
275
+ findings.push(finding(parsed.error === "yaml" ? "plugin.submission.skill.invalid_yaml" : "plugin.submission.skill.invalid_shape", "Skill frontmatter must be a safe YAML mapping.", { path: relativeSkillPath }));
276
+ continue;
277
+ }
278
+ const name = parsed.value.name;
279
+ const description = parsed.value.description;
280
+ const normalizedName = typeof name === "string" ? name.normalize("NFKC").trim() : "";
281
+ const pluginName = typeof manifest.name === "string" ? manifest.name.normalize("NFKC").trim() : "";
282
+ if (!supportedText(name) || !supportedText(description, 1024) || normalizedName.length === 0
283
+ || `${pluginName}:${normalizedName}`.length > 64 || identities.has(normalizedName)) {
284
+ findings.push(finding("plugin.submission.skill.identity", "Skill identity metadata is invalid or duplicated.", { path: relativeSkillPath, limit: 64 }));
285
+ continue;
286
+ }
287
+ identities.add(normalizedName);
288
+ skillCount += 1;
289
+ const agentFindings = await validateAgentFile(rootPath, skillRoot, packagePath(rootPath, skillRoot), budget);
290
+ findings.push(...agentFindings);
291
+ if (agentFindings.some((item) => item.id === "plugin.submission.skill.budget_exceeded"))
292
+ break;
293
+ }
294
+ if (targetType === "skills-only" && skillCount === 0 && !findings.some((item) => item.id === "plugin.submission.skill.required")) {
295
+ findings.push(finding("plugin.submission.skill.required", "Skills-only submissions require at least one valid skill.", { count: 0 }));
296
+ }
297
+ if (targetType === "mcp-backed" && skillCount === 0 && findings.length === 0) {
298
+ findings.push(finding("plugin.submission.skill.invalid_file", "Declared skills must include at least one valid immediate skill entrypoint.", { path: "skills" }));
299
+ }
300
+ return { findings, skillCount };
301
+ }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,11 @@ export { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
14
14
  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";
15
15
  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";
16
16
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson, type DoctorOutputContract, type OutputContractRule, type OutputContractSchema } from "./core/output-contract.js";
17
+ export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
18
+ export { buildSubmissionPreflight, type SubmissionCheck, type SubmissionFinding, type SubmissionManualCheck, type SubmissionPreflightReport } from "./core/submission-preflight.js";
19
+ export { validateSubmissionAssets, type SubmissionAssetResult } from "./core/submission-assets.js";
20
+ export { validateSubmissionSkillMetadata, type SubmissionSkillMetadataResult } from "./core/submission-skill-metadata.js";
21
+ export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
17
22
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport, type BuildDoctorValidationCorpusOptions, type DoctorValidationCorpusReport, type ValidationCorpusCaseDefinition, type ValidationCorpusCaseResult } from "./core/validation-corpus.js";
18
23
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
19
24
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
package/dist/index.js CHANGED
@@ -14,6 +14,11 @@ export { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
14
14
  export { buildExternalValidationCorpusReport, ExternalCorpusManifestError, loadExternalCorpusManifest } from "./core/external-validation-corpus.js";
15
15
  export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, loadCorpusMetricsManifest, reconcileCorpusMetricFindings, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
16
16
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
17
+ export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
18
+ export { buildSubmissionPreflight } from "./core/submission-preflight.js";
19
+ export { validateSubmissionAssets } from "./core/submission-assets.js";
20
+ export { validateSubmissionSkillMetadata } from "./core/submission-skill-metadata.js";
21
+ export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
17
22
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
18
23
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
19
24
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
@@ -0,0 +1,5 @@
1
+ import type { SubmissionPreflightReport } from "../core/submission-preflight.js";
2
+ export declare function renderSubmissionPreflightJson(report: SubmissionPreflightReport): string;
3
+ export declare function renderSubmissionPreflightText(report: SubmissionPreflightReport): string;
4
+ export declare function renderSubmissionPreflightMarkdown(report: SubmissionPreflightReport): string;
5
+ export declare function submissionPreflightExitCode(report: SubmissionPreflightReport, requireReady: boolean): 0 | 1;
@@ -0,0 +1,68 @@
1
+ function upper(value) {
2
+ return value.replace(/[-_]/gu, " ").toUpperCase();
3
+ }
4
+ function findingsByCheck(report) {
5
+ const findings = new Map(report.findings.map((finding) => [finding.id, finding]));
6
+ return report.checks.map((check) => ({
7
+ check,
8
+ findings: check.findingIds.flatMap((id) => {
9
+ const finding = findings.get(id);
10
+ return finding ? [finding] : [];
11
+ })
12
+ }));
13
+ }
14
+ function escapeMarkdown(value) {
15
+ return value.replace(/[\\`*_{}\[\]()<>#+\-.!|]/gu, "\\$&");
16
+ }
17
+ function textSummary(report) {
18
+ return [
19
+ `Ruleset: ${report.rulesetVersion}`,
20
+ `Target: ${report.targetType}`,
21
+ `Automatic status: ${upper(report.status)}`,
22
+ `Readiness: ${upper(report.readiness)}`,
23
+ `Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
24
+ "Manual review is required; automatic checks do not complete directory review."
25
+ ];
26
+ }
27
+ export function renderSubmissionPreflightJson(report) {
28
+ return `${JSON.stringify(report, null, 2)}\n`;
29
+ }
30
+ export function renderSubmissionPreflightText(report) {
31
+ const lines = ["Submission preflight", "====================", ...textSummary(report), "", "Findings by check"];
32
+ for (const { check, findings } of findingsByCheck(report)) {
33
+ lines.push(`${check.id} (${upper(check.status)})`);
34
+ lines.push(...(findings.length === 0
35
+ ? [" None"]
36
+ : findings.map((finding) => ` ${upper(finding.severity)} ${finding.id}: ${finding.message}`)));
37
+ }
38
+ lines.push("", "Manual checklist");
39
+ lines.push(...report.manualChecklist.map((item) => ` ${upper(item.state)} ${item.id}: ${item.label}`));
40
+ return `${lines.join("\n")}\n`;
41
+ }
42
+ export function renderSubmissionPreflightMarkdown(report) {
43
+ const lines = [
44
+ "# Submission preflight",
45
+ "",
46
+ `- Ruleset: ${escapeMarkdown(report.rulesetVersion)}`,
47
+ `- Target: ${escapeMarkdown(report.targetType)}`,
48
+ `- Automatic status: ${escapeMarkdown(upper(report.status))}`,
49
+ `- Readiness: ${escapeMarkdown(upper(report.readiness))}`,
50
+ `- Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
51
+ "",
52
+ "Manual review is required; automatic checks do not complete directory review.",
53
+ "",
54
+ "## Findings by check"
55
+ ];
56
+ for (const { check, findings } of findingsByCheck(report)) {
57
+ lines.push("", `### ${escapeMarkdown(check.id)} (${escapeMarkdown(upper(check.status))})`);
58
+ lines.push(...(findings.length === 0
59
+ ? ["- None"]
60
+ : findings.map((finding) => `- **${escapeMarkdown(upper(finding.severity))}** ${escapeMarkdown(finding.id)}: ${escapeMarkdown(finding.message)}`)));
61
+ }
62
+ lines.push("", "## Manual checklist");
63
+ lines.push(...report.manualChecklist.map((item) => `- ${escapeMarkdown(upper(item.state))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.label)}`));
64
+ return `${lines.join("\n")}\n`;
65
+ }
66
+ export function submissionPreflightExitCode(report, requireReady) {
67
+ return requireReady && report.status === "fail" ? 1 : 0;
68
+ }
package/dist/run-cli.js CHANGED
@@ -19,6 +19,7 @@ import { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRe
19
19
  import { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
20
20
  import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestationJson, renderDoctorAttestationVerification, renderDoctorAttestationVerificationJson, verifyDoctorAttestation } from "./core/attestation.js";
21
21
  import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
22
+ import { buildSubmissionPreflight } from "./core/submission-preflight.js";
22
23
  import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
23
24
  import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
24
25
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
@@ -57,6 +58,7 @@ import { renderSuppressionList, renderSuppressionListJson, renderSuppressionMuta
57
58
  import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
58
59
  import { renderSarifReport } from "./reporting/render-sarif-report.js";
59
60
  import { renderTextReport } from "./reporting/render-text-report.js";
61
+ import { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
60
62
  import { applyPolicyToDepAudit, applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
61
63
  import { findRuleDefinition } from "./rules/rule-catalog.js";
62
64
  import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
@@ -86,6 +88,13 @@ const defaultIo = {
86
88
  }
87
89
  }
88
90
  };
91
+ function writeExactStdout(io, message) {
92
+ if (io === defaultIo) {
93
+ process.stdout.write(message);
94
+ return;
95
+ }
96
+ io.writeStdout(message);
97
+ }
89
98
  class CliUsageError extends Error {
90
99
  }
91
100
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
@@ -177,6 +186,7 @@ function printUsage(io) {
177
186
  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]");
178
187
  io.writeStderr("Baseline gating: codex-plugin-doctor baseline create <path> --output <path> [--runtime]\n codex-plugin-doctor check <path> --baseline <path>");
179
188
  io.writeStderr("Remote MCP runtime flags (check, mcp, release check, and doctor release-evidence): --runtime --allow-network [--allow-local-network] [--allow-session-lifecycle] [--require-remote-reliability]\n --allow-session-lifecycle requires --runtime --allow-network and can terminate a remote session.\n --require-remote-reliability requires --runtime --allow-network, blocks non-pass reliability, and does not grant network consent.");
189
+ io.writeStderr(" codex-plugin-doctor doctor submission <path> [--json|--markdown] [--output <path>] [--require-ready]");
180
190
  }
181
191
  const suppressUsageText = [
182
192
  "Usage:",
@@ -970,6 +980,74 @@ function parseSelectedFixActionIndexes(answer, actionCount) {
970
980
  ? actionIndexes
971
981
  : null;
972
982
  }
983
+ function parseSubmissionCommandArgs(args) {
984
+ let targetPath = null;
985
+ let jsonOutput = false;
986
+ let markdownOutput = false;
987
+ let outputPath = null;
988
+ let requireReady = false;
989
+ let optionsEnded = false;
990
+ for (let index = 0; index < args.length; index += 1) {
991
+ const argument = args[index];
992
+ if (argument === "--" && !optionsEnded) {
993
+ optionsEnded = true;
994
+ }
995
+ else if (optionsEnded) {
996
+ if (targetPath === null) {
997
+ targetPath = argument;
998
+ }
999
+ else {
1000
+ return new CliUsageError(`Unexpected submission argument: ${argument}.`);
1001
+ }
1002
+ }
1003
+ else if (argument === "--json") {
1004
+ if (jsonOutput)
1005
+ return new CliUsageError("Duplicate submission flag: --json.");
1006
+ jsonOutput = true;
1007
+ }
1008
+ else if (argument === "--markdown") {
1009
+ if (markdownOutput)
1010
+ return new CliUsageError("Duplicate submission flag: --markdown.");
1011
+ markdownOutput = true;
1012
+ }
1013
+ else if (argument === "--require-ready") {
1014
+ if (requireReady)
1015
+ return new CliUsageError("Duplicate submission flag: --require-ready.");
1016
+ requireReady = true;
1017
+ }
1018
+ else if (argument === "--output") {
1019
+ if (outputPath !== null)
1020
+ return new CliUsageError("Duplicate submission flag: --output.");
1021
+ const value = args[index + 1];
1022
+ if (!value || value.startsWith("--"))
1023
+ return new CliUsageError("Missing path after --output.");
1024
+ outputPath = value;
1025
+ index += 1;
1026
+ }
1027
+ else if (argument.startsWith("--output=")) {
1028
+ if (outputPath !== null)
1029
+ return new CliUsageError("Duplicate submission flag: --output.");
1030
+ const value = argument.slice("--output=".length);
1031
+ if (!value)
1032
+ return new CliUsageError("Missing path after --output.");
1033
+ outputPath = value;
1034
+ }
1035
+ else if (argument.startsWith("--")) {
1036
+ return new CliUsageError(`Unknown submission flag: ${argument}.`);
1037
+ }
1038
+ else if (targetPath === null) {
1039
+ targetPath = argument;
1040
+ }
1041
+ else {
1042
+ return new CliUsageError(`Unexpected submission argument: ${argument}.`);
1043
+ }
1044
+ }
1045
+ if (targetPath === null)
1046
+ return new CliUsageError("Missing target path for submission.");
1047
+ if (jsonOutput && markdownOutput)
1048
+ return new CliUsageError("Use either --json or --markdown, not both.");
1049
+ return { targetPath, jsonOutput, markdownOutput, outputPath, requireReady };
1050
+ }
973
1051
  export async function runCli(args, io = defaultIo, options = {}) {
974
1052
  const [command, maybePath, ...remainingArgs] = args;
975
1053
  const runtimeSandboxSupported = (command === "check" &&
@@ -1079,6 +1157,24 @@ export async function runCli(args, io = defaultIo, options = {}) {
1079
1157
  : renderDoctorOutputContract(contract, { outputPath }));
1080
1158
  return 0;
1081
1159
  }
1160
+ if (maybePath === "submission") {
1161
+ const parsedSubmissionArgs = parseSubmissionCommandArgs(remainingArgs);
1162
+ if (parsedSubmissionArgs instanceof CliUsageError) {
1163
+ io.writeStderr(parsedSubmissionArgs.message);
1164
+ return 2;
1165
+ }
1166
+ const report = await buildSubmissionPreflight(parsedSubmissionArgs.targetPath);
1167
+ const renderedReport = parsedSubmissionArgs.jsonOutput
1168
+ ? renderSubmissionPreflightJson(report)
1169
+ : parsedSubmissionArgs.markdownOutput
1170
+ ? renderSubmissionPreflightMarkdown(report)
1171
+ : renderSubmissionPreflightText(report);
1172
+ if (parsedSubmissionArgs.outputPath) {
1173
+ await writeFile(parsedSubmissionArgs.outputPath, renderedReport, "utf8");
1174
+ }
1175
+ writeExactStdout(io, renderedReport);
1176
+ return submissionPreflightExitCode(report, parsedSubmissionArgs.requireReady);
1177
+ }
1082
1178
  if (maybePath === "mcp") {
1083
1179
  const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
1084
1180
  ? remainingArgs[0]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.58.0",
3
+ "version": "1.59.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",
@@ -60,5 +60,9 @@
60
60
  "tsx": "^4.20.4",
61
61
  "typescript": "^5.9.2",
62
62
  "vitest": "^3.2.4"
63
+ },
64
+ "dependencies": {
65
+ "fast-xml-parser": "^5.11.0",
66
+ "yaml": "^2.9.0"
63
67
  }
64
68
  }