codex-plugin-doctor 1.59.0 → 1.61.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.
@@ -1,6 +1,6 @@
1
- import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { isAlias, isNode, parseDocument, visit } from "yaml";
3
+ import { createDirectorySubmissionPackageReader } from "./submission-package-reader.js";
4
4
  const maxSkillBytes = 1024 * 1024;
5
5
  const maxAgentBytes = 256 * 1024;
6
6
  const maxAggregateMetadataBytes = 16 * 1024 * 1024;
@@ -15,17 +15,8 @@ const agentKeys = new Set(["interface", "policy", "dependencies"]);
15
15
  function isRecord(value) {
16
16
  return typeof value === "object" && value !== null && !Array.isArray(value);
17
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
18
  function finding(id, message, evidence) {
26
- return evidence === undefined
27
- ? { id, severity: "fail", message }
28
- : { id, severity: "fail", message, evidence };
19
+ return evidence === undefined ? { id, severity: "fail", message } : { id, severity: "fail", message, evidence };
29
20
  }
30
21
  function supportedText(value, limit = Number.MAX_SAFE_INTEGER) {
31
22
  return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !unsupportedText.test(value);
@@ -52,34 +43,53 @@ function parseSafeYaml(source) {
52
43
  }
53
44
  function splitSkillFile(source) {
54
45
  const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source);
55
- if (!match || match[2].trim().length === 0)
46
+ return !match || match[2].trim().length === 0 ? null : { frontmatter: match[1], body: match[2] };
47
+ }
48
+ function skillsRoot(value) {
49
+ if (typeof value !== "string" || !value.startsWith("./") || value.includes("\\") || /[\u0000-\u001F\u007F]/u.test(value))
56
50
  return null;
57
- return { frontmatter: match[1], body: match[2] };
51
+ const normalized = path.posix.normalize(value.slice(2)).replace(/\/$/u, "");
52
+ return normalized === "skills" ? normalized : null;
58
53
  }
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 {
54
+ function relativePackagePath(base, value) {
55
+ if (path.posix.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value))
65
56
  return null;
66
- }
57
+ const resolved = path.posix.normalize(path.posix.join(base, value));
58
+ return resolved === ".." || resolved.startsWith("../") || resolved === "." ? null : resolved;
59
+ }
60
+ function normalizeResolvedPackagePath(value) {
61
+ if (typeof value !== "string" || value === "" || value.includes("\\") || path.posix.isAbsolute(value)
62
+ || /^[A-Za-z]:/u.test(value) || /[\u0000-\u001F\u007F]/u.test(value))
63
+ return null;
64
+ const normalized = path.posix.normalize(value);
65
+ if (normalized === "" || normalized === "." || normalized === ".." || normalized.startsWith("../"))
66
+ return null;
67
+ return normalized;
68
+ }
69
+ function isWithinPackagePath(root, candidate) {
70
+ return candidate === root || candidate.startsWith(`${root}/`);
67
71
  }
68
72
  function budgetFinding(bytes) {
69
73
  return finding("plugin.submission.skill.budget_exceeded", "Skill metadata exceeds the aggregate submission preflight size limit.", {
70
74
  count: bytes, limit: maxAggregateMetadataBytes
71
75
  });
72
76
  }
73
- async function readSafeUtf8(filePath, maximum, budget) {
77
+ async function safeStat(reader, packagePath) {
78
+ return reader.stat(packagePath).catch(() => null);
79
+ }
80
+ async function readSafeUtf8(reader, packagePath, maximum, budget) {
81
+ const details = await safeStat(reader, packagePath);
82
+ if (details === null || details.safeResolution !== "safe" || details.resolvedKind !== "file" || details.size > maximum)
83
+ return { kind: "invalid" };
84
+ const nextBytes = budget.bytes + details.size;
85
+ if (nextBytes > maxAggregateMetadataBytes)
86
+ return { kind: "budget", nextBytes };
87
+ const bytes = await reader.read(packagePath, maximum).catch(() => null);
88
+ if (bytes === null || bytes.byteLength !== details.size)
89
+ return { kind: "invalid" };
74
90
  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
91
  budget.bytes = nextBytes;
82
- return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(await readFile(filePath)) };
92
+ return { kind: "source", source: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
83
93
  }
84
94
  catch {
85
95
  return { kind: "invalid" };
@@ -97,205 +107,176 @@ function isToolDescriptor(value) {
97
107
  && (value.transport === undefined || supportedText(value.transport))
98
108
  && (value.url === undefined || supportedText(value.url));
99
109
  }
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)) {
110
+ async function validateIconPath(reader, skillRoot, skillPath, field, value) {
111
+ if (typeof value !== "string" || value.trim() !== value || !supportedText(value)) {
107
112
  return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
108
113
  }
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 {
114
+ const iconPath = relativePackagePath(skillRoot, value);
115
+ const details = iconPath === null ? null : await safeStat(reader, iconPath);
116
+ if (details === null || details.safeResolution !== "safe" || details.resolvedKind !== "file") {
116
117
  return finding("plugin.submission.skill.agent.invalid_path", "Optional agent icon path is invalid.", { path: skillPath, field });
117
118
  }
119
+ return null;
118
120
  }
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 {
121
+ async function validateAgentFile(reader, skillRoot, resolvedSkillRoot, budget) {
122
+ const skillPath = skillRoot;
123
+ const agentPath = `${skillRoot}/agents/openai.yaml`;
124
+ const agentDetails = await safeStat(reader, agentPath);
125
+ if (agentDetails === null)
126
126
  return [];
127
- }
128
- if (agentDetails.isSymbolicLink()) {
127
+ if (agentDetails.kind === "symlink") {
129
128
  return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata must not be a symbolic link.", { path: skillPath })];
130
129
  }
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) })];
130
+ if (agentDetails.safeResolution === "outside") {
131
+ return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })];
133
132
  }
134
- let canonicalRoot;
135
- let canonicalSkill;
136
- let canonicalAgent;
137
- try {
138
- [canonicalRoot, canonicalSkill, canonicalAgent] = await Promise.all([realpath(rootPath), realpath(skillRoot), realpath(agentPath)]);
133
+ if (agentDetails.safeResolution !== "safe") {
134
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a readable regular file.", { path: agentPath })];
139
135
  }
140
- catch {
141
- return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a readable regular file.", { path: packagePath(rootPath, agentPath) })];
136
+ if (agentDetails.resolvedKind !== "file") {
137
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a regular file.", { path: agentPath })];
142
138
  }
143
- if (!isWithin(canonicalRoot, canonicalAgent) || !isWithin(canonicalSkill, canonicalAgent)) {
139
+ const normalizedSkillRoot = normalizeResolvedPackagePath(resolvedSkillRoot);
140
+ const resolvedAgentPath = normalizeResolvedPackagePath(agentDetails.resolvedPackagePath ?? agentPath);
141
+ if (normalizedSkillRoot === null || resolvedAgentPath === null || !isWithinPackagePath(normalizedSkillRoot, resolvedAgentPath)) {
144
142
  return [finding("plugin.submission.skill.agent.invalid_path", "Optional agent metadata resolves outside its skill.", { path: skillPath })];
145
143
  }
146
- const source = await readSafeUtf8(agentPath, maxAgentBytes, budget);
147
- if (source.kind === "budget") {
144
+ const source = await readSafeUtf8(reader, agentPath, maxAgentBytes, budget);
145
+ if (source.kind === "budget")
148
146
  return [budgetFinding(source.nextBytes)];
149
- }
150
147
  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 })];
148
+ return [finding("plugin.submission.skill.agent.invalid_file", "Optional agent metadata must be a bounded UTF-8 regular file.", { path: agentPath, limit: maxAgentBytes })];
152
149
  }
153
150
  const parsed = parseSafeYaml(source.source);
154
151
  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) })];
152
+ 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: agentPath })];
156
153
  }
157
154
  const metadata = parsed.value;
158
155
  if (rejectUnknownKeys(metadata, agentKeys) || !isRecord(metadata.interface) || rejectUnknownKeys(metadata.interface, interfaceKeys)
159
156
  || !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) })];
157
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an unsupported shape.", { path: agentPath })];
161
158
  }
162
159
  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" })];
160
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid brand color.", { path: agentPath, field: "brand_color" })];
164
161
  }
165
162
  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" })];
163
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent metadata has an invalid default prompt.", { path: agentPath, field: "default_prompt" })];
167
164
  }
168
165
  if (metadata.policy !== undefined && (!isRecord(metadata.policy) || rejectUnknownKeys(metadata.policy, policyKeys)
169
166
  || (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")))
167
+ || new Set(metadata.policy.products).size !== metadata.policy.products.length || metadata.policy.products.some((product) => product !== "CHAT" && product !== "CODEX")))
172
168
  || (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" })];
169
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent policy has an unsupported shape.", { path: agentPath, field: "policy" })];
174
170
  }
175
171
  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" })];
172
+ || !Array.isArray(metadata.dependencies.tools) || metadata.dependencies.tools.length === 0 || metadata.dependencies.tools.some((tool) => !isToolDescriptor(tool)))) {
173
+ return [finding("plugin.submission.skill.agent.invalid_shape", "Optional agent dependencies have an unsupported shape.", { path: agentPath, field: "dependencies" })];
179
174
  }
180
175
  for (const field of ["icon_small", "icon_large"]) {
181
176
  const value = metadata.interface[field];
182
177
  if (value === undefined)
183
178
  continue;
184
- const iconFinding = await validateIconPath(rootPath, canonicalRoot, skillRoot, skillPath, field, value);
185
- if (iconFinding)
179
+ const iconFinding = await validateIconPath(reader, skillRoot, skillPath, field, value);
180
+ if (iconFinding !== null)
186
181
  return [iconFinding];
187
182
  }
188
183
  return [];
189
184
  }
190
- export async function validateSubmissionSkillMetadata(discoveredPackage, targetType) {
191
- const { manifest, rootPath } = discoveredPackage;
185
+ export async function validateSubmissionSkillMetadataFromReader(manifest, targetType, reader) {
192
186
  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 };
187
+ return targetType === "skills-only" ? { findings: [finding("plugin.submission.skill.required", "Skills-only submissions require a valid skill.")], skillCount: 0 } : { findings: [], skillCount: 0 };
196
188
  }
197
- if (manifest.skills !== "./skills" && manifest.skills !== "./skills/") {
189
+ const root = skillsRoot(manifest.skills);
190
+ if (root === null) {
198
191
  return { findings: [finding("plugin.submission.skill.invalid_manifest", "Skills must be declared as the root ./skills directory.", { field: "skills" })], skillCount: 0 };
199
192
  }
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 };
193
+ const rootDetails = await safeStat(reader, root);
194
+ if (rootDetails === null || rootDetails.safeResolution !== "safe" || rootDetails.resolvedKind !== "directory") {
195
+ return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory must be canonically contained in the package.", { path: root })], skillCount: 0 };
196
+ }
197
+ const listed = await reader.list(root).catch(() => null);
198
+ if (listed === null) {
199
+ return { findings: [finding("plugin.submission.skill.invalid_path", "Skills directory cannot be inspected safely.", { path: root })], skillCount: 0 };
204
200
  }
205
201
  const findings = [];
206
202
  const identities = new Set();
207
- let skillCount = 0;
208
203
  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;
204
+ let skillCount = 0;
217
205
  let skillDirectoryCount = 0;
218
- for await (const entry of entries) {
219
- entryCount += 1;
206
+ const entries = [...listed].sort((left, right) => left.packagePath.localeCompare(right.packagePath));
207
+ for (let index = 0; index < entries.length; index += 1) {
208
+ const entry = entries[index];
209
+ const entryCount = index + 1;
220
210
  if (entryCount > maxDirectoryEntries) {
221
211
  findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight entry limit.", { count: entryCount, limit: maxDirectoryEntries }));
222
212
  break;
223
213
  }
224
- if (entry.name.startsWith(".") || !entry.isDirectory())
214
+ const name = path.posix.basename(entry.packagePath);
215
+ if (path.posix.dirname(entry.packagePath) !== root || name.startsWith(".") || entry.resolvedKind !== "directory")
216
+ continue;
217
+ if (entry.safeResolution !== "safe" || entry.kind === "symlink") {
218
+ findings.push(finding("plugin.submission.skill.invalid_path", "Skill directory resolves outside the declared skills directory.", { path: entry.packagePath }));
225
219
  continue;
220
+ }
226
221
  skillDirectoryCount += 1;
227
222
  if (skillDirectoryCount > maxSkillDirectories) {
228
223
  findings.push(finding("plugin.submission.skill.too_many", "Skills directory exceeds the submission preflight skill limit.", { count: skillDirectoryCount, limit: maxSkillDirectories }));
229
224
  break;
230
225
  }
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) }));
226
+ const skillRoot = entry.packagePath;
227
+ const skillFile = `${skillRoot}/SKILL.md`;
228
+ const fileDetails = await safeStat(reader, skillFile);
229
+ if (fileDetails?.kind === "symlink" || fileDetails?.safeResolution === "outside") {
230
+ findings.push(finding("plugin.submission.skill.invalid_path", "Skill entrypoint must not be a symbolic link.", { path: skillFile }));
242
231
  continue;
243
232
  }
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 }));
233
+ if (fileDetails === null || fileDetails.safeResolution !== "safe" || fileDetails.resolvedKind !== "file") {
234
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a contained regular file.", { path: skillFile }));
257
235
  continue;
258
236
  }
259
- const source = await readSafeUtf8(skillFile, maxSkillBytes, budget);
237
+ const source = await readSafeUtf8(reader, skillFile, maxSkillBytes, budget);
260
238
  if (source.kind === "budget") {
261
239
  findings.push(budgetFinding(source.nextBytes));
262
240
  break;
263
241
  }
264
242
  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 }));
243
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint must be a bounded UTF-8 regular file.", { path: skillFile, limit: maxSkillBytes }));
266
244
  continue;
267
245
  }
268
246
  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 }));
247
+ if (split === null) {
248
+ findings.push(finding("plugin.submission.skill.invalid_file", "Skill entrypoint requires delimited frontmatter and a nonempty body.", { path: skillFile }));
271
249
  continue;
272
250
  }
273
251
  const parsed = parseSafeYaml(split.frontmatter);
274
252
  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 }));
253
+ 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: skillFile }));
276
254
  continue;
277
255
  }
278
- const name = parsed.value.name;
256
+ const nameValue = parsed.value.name;
279
257
  const description = parsed.value.description;
280
- const normalizedName = typeof name === "string" ? name.normalize("NFKC").trim() : "";
258
+ const normalizedName = typeof nameValue === "string" ? nameValue.normalize("NFKC").trim() : "";
281
259
  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 }));
260
+ if (!supportedText(nameValue) || !supportedText(description, 1024) || normalizedName.length === 0 || `${pluginName}:${normalizedName}`.length > 64 || identities.has(normalizedName)) {
261
+ findings.push(finding("plugin.submission.skill.identity", "Skill identity metadata is invalid or duplicated.", { path: skillFile, limit: 64 }));
285
262
  continue;
286
263
  }
287
264
  identities.add(normalizedName);
288
265
  skillCount += 1;
289
- const agentFindings = await validateAgentFile(rootPath, skillRoot, packagePath(rootPath, skillRoot), budget);
266
+ const resolvedSkillRoot = entry.resolvedPackagePath ?? skillRoot;
267
+ const agentFindings = await validateAgentFile(reader, skillRoot, resolvedSkillRoot, budget);
290
268
  findings.push(...agentFindings);
291
269
  if (agentFindings.some((item) => item.id === "plugin.submission.skill.budget_exceeded"))
292
270
  break;
293
271
  }
294
- if (targetType === "skills-only" && skillCount === 0 && !findings.some((item) => item.id === "plugin.submission.skill.required")) {
272
+ if (targetType === "skills-only" && skillCount === 0) {
295
273
  findings.push(finding("plugin.submission.skill.required", "Skills-only submissions require at least one valid skill.", { count: 0 }));
296
274
  }
297
275
  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" }));
276
+ findings.push(finding("plugin.submission.skill.invalid_file", "Declared skills must include at least one valid immediate skill entrypoint.", { path: root }));
299
277
  }
300
278
  return { findings, skillCount };
301
279
  }
280
+ export async function validateSubmissionSkillMetadata(discoveredPackage, targetType) {
281
+ return validateSubmissionSkillMetadataFromReader(discoveredPackage.manifest, targetType, createDirectorySubmissionPackageReader(discoveredPackage.rootPath));
282
+ }
package/dist/index.d.ts CHANGED
@@ -16,9 +16,11 @@ export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, C
16
16
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson, type DoctorOutputContract, type OutputContractRule, type OutputContractSchema } from "./core/output-contract.js";
17
17
  export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
18
18
  export { buildSubmissionPreflight, type SubmissionCheck, type SubmissionFinding, type SubmissionManualCheck, type SubmissionPreflightReport } from "./core/submission-preflight.js";
19
+ export { buildSubmissionArchivePreflight, submissionArchiveExitCode, type SubmissionArchivePreflightReport, type SubmissionArchiveRootLayout } from "./core/submission-archive-preflight.js";
19
20
  export { validateSubmissionAssets, type SubmissionAssetResult } from "./core/submission-assets.js";
20
21
  export { validateSubmissionSkillMetadata, type SubmissionSkillMetadataResult } from "./core/submission-skill-metadata.js";
21
22
  export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
23
+ export { renderSubmissionArchiveJson, renderSubmissionArchiveMarkdown, renderSubmissionArchiveText } from "./reporting/render-submission-archive-report.js";
22
24
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport, type BuildDoctorValidationCorpusOptions, type DoctorValidationCorpusReport, type ValidationCorpusCaseDefinition, type ValidationCorpusCaseResult } from "./core/validation-corpus.js";
23
25
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
24
26
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
package/dist/index.js CHANGED
@@ -16,9 +16,11 @@ export { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, C
16
16
  export { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
17
17
  export { submissionRuleset, submissionManualChecks } from "./core/submission-ruleset.js";
18
18
  export { buildSubmissionPreflight } from "./core/submission-preflight.js";
19
+ export { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./core/submission-archive-preflight.js";
19
20
  export { validateSubmissionAssets } from "./core/submission-assets.js";
20
21
  export { validateSubmissionSkillMetadata } from "./core/submission-skill-metadata.js";
21
22
  export { renderSubmissionPreflightJson, renderSubmissionPreflightMarkdown, renderSubmissionPreflightText, submissionPreflightExitCode } from "./reporting/render-submission-report.js";
23
+ export { renderSubmissionArchiveJson, renderSubmissionArchiveMarkdown, renderSubmissionArchiveText } from "./reporting/render-submission-archive-report.js";
22
24
  export { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
23
25
  export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
24
26
  export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
@@ -0,0 +1,2 @@
1
+ import type { McpDiscoveryReport } from "../core/mcp-discovery.js";
2
+ export declare function renderMcpDiscoveryReport(report: McpDiscoveryReport): string;
@@ -0,0 +1,14 @@
1
+ export function renderMcpDiscoveryReport(report) {
2
+ return [
3
+ "MCP Discovery",
4
+ "=============",
5
+ `Status: ${report.status}`,
6
+ `Scope: ${report.scope}`,
7
+ `Requested protocol: ${report.requestedVersion}`,
8
+ `Advertised versions: ${report.supportedVersions.join(", ") || "not available"}`,
9
+ `Discovery response: ${report.coverage.discovery}`,
10
+ `Runtime: ${report.coverage.runtime}`,
11
+ "Discovery does not establish full protocol conformance or client compatibility.",
12
+ ...report.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`)
13
+ ].join("\n");
14
+ }
@@ -0,0 +1,4 @@
1
+ import type { SubmissionArchivePreflightReport } from "../core/submission-archive-preflight.js";
2
+ export declare function renderSubmissionArchiveJson(report: SubmissionArchivePreflightReport): string;
3
+ export declare function renderSubmissionArchiveText(report: SubmissionArchivePreflightReport): string;
4
+ export declare function renderSubmissionArchiveMarkdown(report: SubmissionArchivePreflightReport): string;
@@ -0,0 +1,52 @@
1
+ function upper(value) {
2
+ return value.replace(/[-_]/gu, " ").toUpperCase();
3
+ }
4
+ function escapeMarkdown(value) {
5
+ return value.replace(/[\\`*_{}\[\]()<>#+\-.!|]/gu, "\\$&");
6
+ }
7
+ function summary(report) {
8
+ return [
9
+ `Ruleset: ${report.rulesetVersion}`,
10
+ `Archive: ${report.archive.fileName}`,
11
+ `Root layout: ${report.archive.rootLayout}`,
12
+ `Automatic status: ${upper(report.status)}`,
13
+ `Readiness: ${upper(report.readiness)}`,
14
+ `Summary: ${report.summary.passed} passed, ${report.summary.warnings} warnings, ${report.summary.blockers} blockers, ${report.summary.manualChecks} manual checks`,
15
+ "Manual review is required; automatic checks do not complete archive review."
16
+ ];
17
+ }
18
+ export function renderSubmissionArchiveJson(report) {
19
+ return `${JSON.stringify(report, null, 2)}\n`;
20
+ }
21
+ export function renderSubmissionArchiveText(report) {
22
+ const lines = ["Submission archive preflight", "============================", ...summary(report), "", "Findings"];
23
+ lines.push(...(report.findings.length === 0
24
+ ? [" None"]
25
+ : report.findings.map((finding) => ` ${upper(finding.severity)} ${finding.id}: ${finding.message}`)));
26
+ lines.push("", "Coverage");
27
+ lines.push(...(report.coverage.length === 0
28
+ ? [" None"]
29
+ : report.coverage.map((item) => ` ${upper(item.status)} ${item.id}: ${item.reason}`)));
30
+ lines.push("", "Manual checklist");
31
+ lines.push(...report.manualChecklist.map((item) => ` ${upper(item.state)} ${item.id}: ${item.label}`));
32
+ return `${lines.join("\n")}\n`;
33
+ }
34
+ export function renderSubmissionArchiveMarkdown(report) {
35
+ const lines = [
36
+ "# Submission archive preflight",
37
+ "",
38
+ ...summary(report).map((item) => `- ${escapeMarkdown(item)}`),
39
+ "",
40
+ "## Findings"
41
+ ];
42
+ lines.push(...(report.findings.length === 0
43
+ ? ["- None"]
44
+ : report.findings.map((finding) => `- **${escapeMarkdown(upper(finding.severity))}** ${escapeMarkdown(finding.id)}: ${escapeMarkdown(finding.message)}`)));
45
+ lines.push("", "## Coverage");
46
+ lines.push(...(report.coverage.length === 0
47
+ ? ["- None"]
48
+ : report.coverage.map((item) => `- ${escapeMarkdown(upper(item.status))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.reason)}`)));
49
+ lines.push("", "## Manual checklist");
50
+ lines.push(...report.manualChecklist.map((item) => `- ${escapeMarkdown(upper(item.state))}: ${escapeMarkdown(item.id)} — ${escapeMarkdown(item.label)}`));
51
+ return `${lines.join("\n")}\n`;
52
+ }
package/dist/run-cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { discoverMcpServer } from "./core/mcp-discovery.js";
1
2
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
2
3
  import { runCheck } from "./index.js";
3
4
  import { writeRawDoctorConfig } from "./index.js";
@@ -13,6 +14,7 @@ export interface CliTerminalContext {
13
14
  platform?: NodeJS.Platform;
14
15
  }
15
16
  export interface RunCliOptions {
17
+ discoverMcpServerImpl?: typeof discoverMcpServer;
16
18
  terminalContext?: CliTerminalContext;
17
19
  runCheckImpl?: typeof runCheck;
18
20
  writeRawDoctorConfigImpl?: typeof writeRawDoctorConfig;