From 0a610680b4f9ebab37db20907991c34ce80a0d34 Mon Sep 17 00:00:00 2001 From: Mike North Date: Sun, 19 Jul 2026 11:21:08 -0700 Subject: [PATCH 1/2] Fix aipm lint silent for a plugin-shaped dir missing aipm.config.ts #91 made validate (hard envelope-invalid) and build (thrown ConfigLoadError) catch a plugin-shaped directory missing aipm.config.ts, but lint stayed silent for the identical tree. schema/envelope-shape now also fires on a missing config, emitting the equivalent envelope-invalid diagnostic lint and validate agree on. Refs #101 --- .../lint-missing-config-envelope-invalid.md | 10 ++++ docs/specs/lint-engine.md | 5 +- packages/core/src/lint/engine.test.ts | 46 +++++++++++++++++++ .../src/lint/rules/legacy-envelope-shape.ts | 23 ++++++++-- 4 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 .changeset/lint-missing-config-envelope-invalid.md diff --git a/.changeset/lint-missing-config-envelope-invalid.md b/.changeset/lint-missing-config-envelope-invalid.md new file mode 100644 index 0000000..e293d60 --- /dev/null +++ b/.changeset/lint-missing-config-envelope-invalid.md @@ -0,0 +1,10 @@ +--- +'@ai-plugin-marketplace/core': patch +--- + +Fix `aipm lint` staying silent for a plugin-shaped directory missing `aipm.config.ts` — a case +`aipm validate` (hard `envelope-invalid`) and `aipm build` (thrown `ConfigLoadError`) already +caught. The `schema/envelope-shape` rule now also fires when the config file is absent, emitting +the same `envelope-invalid`-backed, `error`-severity diagnostic `validate` reports for the +identical tree, so the three surfaces agree instead of `lint` reporting a clean bill of health for +a broken plugin. diff --git a/docs/specs/lint-engine.md b/docs/specs/lint-engine.md index e8aebf6..13502d0 100644 --- a/docs/specs/lint-engine.md +++ b/docs/specs/lint-engine.md @@ -213,8 +213,9 @@ The existing zod schemas (per-target `schemas.ts`, config schemas in `config.ts` through L-D3 so every issue has a range. Zod remains the sole authority (L-D8). Two migrated rules carry this category: -- `schema/envelope-shape` — `aipm.config.ts` parses strictly against the envelope schema; legacy - code `envelope-invalid`. +- `schema/envelope-shape` — `aipm.config.ts` parses strictly against the envelope schema, and + also fires when `aipm.config.ts` is missing entirely from a plugin-shaped directory (#101, + mirroring `validate()`'s `envelope-invalid` for the same case); legacy code `envelope-invalid`. - `schema/target-conformance` — every target manifest in a plugin's envelope parses against that target's current schema; legacy codes `schema-invalid`, plus the Open Plugins-specific `metadata-dir-isolation` and `open-plugins-conformance` (soft, advisory-only) findings the same diff --git a/packages/core/src/lint/engine.test.ts b/packages/core/src/lint/engine.test.ts index e7af43c..f817b85 100644 --- a/packages/core/src/lint/engine.test.ts +++ b/packages/core/src/lint/engine.test.ts @@ -11,6 +11,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { runBuild } from '../pipeline/build.js'; +import { runValidate } from '../pipeline/validate.js'; import { synthRegistryRepo } from '../test-support/synth-plugin.js'; import type { SynthRegistryRepo } from '../test-support/synth-plugin.js'; import { lint } from './engine.js'; @@ -220,3 +221,48 @@ describe('lint()', () => { }); }); }); + +// Regression coverage for #101: #91 made `runValidate` (hard `envelope-invalid`) and `runBuild` +// (thrown `ConfigLoadError`) catch a plugin-shaped directory missing `aipm.config.ts`, but `lint()` +// stayed silent for the identical tree. `lint` and `validate` must agree on this case. +describe('lint() — plugin-shaped repo-root subdirectory missing aipm.config.ts (#91, #101)', () => { + it('agrees with runValidate(): both report the equivalent envelope-invalid finding for the same tree', async () => { + // Repro shape from #91: a plugins/* dir with a target manifest but no aipm.config.ts. + write('plugins/broken/.claude-plugin/plugin.json', { name: 'broken', version: '0.1.0' }); + + const [lintResult, validateResult] = await Promise.all([lint(repoRoot), runValidate(repoRoot)]); + + // validate() surfaces its existing hard envelope-invalid finding (unchanged by this fix, #91). + const envelopeFindings = validateResult.findings.filter((f) => f.code === 'envelope-invalid'); + expect(envelopeFindings).toHaveLength(1); + expect(envelopeFindings[0]?.plugin).toBe('broken'); + expect(envelopeFindings[0]?.severity).toBe('hard'); + expect(validateResult.passed).toBe(false); + + // lint() must now surface the equivalent diagnostic — same rule as a malformed config + // (`schema/envelope-shape`), `error` severity (validate's `hard` maps to lint's `error`, + // L-D2's findingToDiagnostic), carrying the legacy `envelope-invalid` code, and the identical + // message validate reports — not silent, as it was pre-fix. + const lintEnvelopeDiagnostics = lintResult.diagnostics.filter( + (d) => d.legacyCode === 'envelope-invalid', + ); + expect(lintEnvelopeDiagnostics).toHaveLength(1); + expect(lintEnvelopeDiagnostics[0]).toMatchObject({ + ruleId: 'schema/envelope-shape', + category: 'schema', + severity: 'error', + file: 'broken', + }); + expect(lintEnvelopeDiagnostics[0]?.message).toBe(envelopeFindings[0]?.message); + }); + + it('emits nothing further beyond the envelope-invalid diagnostic for the config-less plugin', async () => { + // No other rule can run without a resolved envelope (engine.ts's `continue` after the failed + // loadPluginConfig call) — mirroring validate()'s short-circuit on an unusable envelope. + write('plugins/broken/.claude-plugin/plugin.json', { name: 'broken', version: '0.1.0' }); + + const result = await lint(repoRoot); + + expect(result.diagnostics.filter((d) => d.file === 'broken')).toHaveLength(1); + }); +}); diff --git a/packages/core/src/lint/rules/legacy-envelope-shape.ts b/packages/core/src/lint/rules/legacy-envelope-shape.ts index f23ef0f..5220dd5 100644 --- a/packages/core/src/lint/rules/legacy-envelope-shape.ts +++ b/packages/core/src/lint/rules/legacy-envelope-shape.ts @@ -5,8 +5,10 @@ * Unlike the other migrated rules, this one runs *before* a plugin's envelope (`ctx.envelope`) * is known — it is what determines whether the envelope can be trusted at all. It therefore * loads the raw `aipm.config.ts` default export itself rather than reading anything off - * `RuleContext.envelope`, and reports nothing when the config is missing entirely (a missing - * envelope is `load-config.ts`'s concern, not a shape-validation one). + * `RuleContext.envelope`. A missing `aipm.config.ts` (including a plugin-shaped directory + * discovered without one, per #91) is reported here too, as an `envelope-invalid` diagnostic + * equivalent to `validate()`'s — `lint` and `validate` must agree on this case rather than one + * staying silent while the other flags it (#101). * * Reuses `ctx.configCache` (via {@link loadPluginConfig}) rather than importing the raw config a * second time: `loadPluginConfig` transpiles-and-validates once and caches the result, so when @@ -46,8 +48,23 @@ export const envelopeShapeRule: Rule = { }, async check(ctx: InternalRuleContext): Promise { const configPath = path.join(ctx.pluginDir, AIPM_CONFIG_FILENAME); - if (!fs.existsSync(configPath)) return []; const pluginName = path.basename(ctx.pluginDir); + if (!fs.existsSync(configPath)) { + // Mirrors validate()'s `envelope-invalid` finding for the identical failure (#91's + // discovery path surfaces a plugin-shaped, config-less directory; without this, lint stayed + // silent while validate/build both flagged it — #101). loadPluginConfig throws the same + // ConfigLoadError message for this case; reproduce it directly here rather than calling + // loadPluginConfig only to immediately catch it, since this path never reaches the cache. + const message = `No ${AIPM_CONFIG_FILENAME} found in ${ctx.pluginDir}. Every plugin must declare a support envelope (spec §6.1).`; + return [ + findingToDiagnostic( + { severity: 'hard', code: 'envelope-invalid', plugin: pluginName, message }, + RULE_ID, + 'schema', + docsUrlFor(RULE_ID), + ), + ]; + } try { // A cache hit here (already validated by an earlier call in this invocation) means the // envelope is known-valid — nothing further to report. From 83c60f892ee2d296c76e86c61660eb8554727c46 Mon Sep 17 00:00:00 2001 From: Mike North Date: Thu, 27 Aug 2026 12:07:42 -0700 Subject: [PATCH 2/2] Report every aipm.config.ts load failure from schema/envelope-shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule pre-checked for a missing aipm.config.ts and returned a hard-coded copy of the message loadPluginConfig throws for that case, then returned no diagnostic at all for any non-Zod load failure. A config with a syntax error or no usable default export therefore still passed aipm lint silently while validate and build both reported envelope-invalid — the same disagreement the missing-config fix set out to close. Drop the existence pre-check and derive every diagnostic from the thrown error, mirroring runValidate()'s envelope-load catch: a ZodError cause expands into per-issue diagnostics, anything else becomes a single envelope-invalid carrying the loader's own message. Agreement with validate is now structural rather than a duplicated string, and covers missing, unimportable and schema-violating configs alike. Also corrects the lint engine's comment on its envelope short-circuit, which claimed the config loader explains an unusable envelope on its own. --- .../lint-missing-config-envelope-invalid.md | 12 ++-- docs/specs/lint-engine.md | 10 ++- packages/core/src/lint/engine.test.ts | 57 ++++++++++++++++ packages/core/src/lint/engine.ts | 5 +- .../src/lint/rules/legacy-envelope-shape.ts | 68 ++++++++----------- 5 files changed, 102 insertions(+), 50 deletions(-) diff --git a/.changeset/lint-missing-config-envelope-invalid.md b/.changeset/lint-missing-config-envelope-invalid.md index e293d60..f5fc9ab 100644 --- a/.changeset/lint-missing-config-envelope-invalid.md +++ b/.changeset/lint-missing-config-envelope-invalid.md @@ -2,9 +2,11 @@ '@ai-plugin-marketplace/core': patch --- -Fix `aipm lint` staying silent for a plugin-shaped directory missing `aipm.config.ts` — a case +Fix `aipm lint` staying silent for a plugin whose `aipm.config.ts` cannot be resolved — cases `aipm validate` (hard `envelope-invalid`) and `aipm build` (thrown `ConfigLoadError`) already -caught. The `schema/envelope-shape` rule now also fires when the config file is absent, emitting -the same `envelope-invalid`-backed, `error`-severity diagnostic `validate` reports for the -identical tree, so the three surfaces agree instead of `lint` reporting a clean bill of health for -a broken plugin. +caught. The `schema/envelope-shape` rule now fires for every envelope-load failure, not only a +schema violation: a plugin-shaped directory missing `aipm.config.ts`, and a config file that is +present but cannot be imported (syntax error, no usable default export). Both emit the same +`envelope-invalid`-backed, `error`-severity diagnostic `validate` reports for the identical tree, +carrying the config loader's own message verbatim, so the three surfaces agree instead of `lint` +reporting a clean bill of health for a broken plugin. diff --git a/docs/specs/lint-engine.md b/docs/specs/lint-engine.md index 13502d0..6d8e5e3 100644 --- a/docs/specs/lint-engine.md +++ b/docs/specs/lint-engine.md @@ -213,9 +213,13 @@ The existing zod schemas (per-target `schemas.ts`, config schemas in `config.ts` through L-D3 so every issue has a range. Zod remains the sole authority (L-D8). Two migrated rules carry this category: -- `schema/envelope-shape` — `aipm.config.ts` parses strictly against the envelope schema, and - also fires when `aipm.config.ts` is missing entirely from a plugin-shaped directory (#101, - mirroring `validate()`'s `envelope-invalid` for the same case); legacy code `envelope-invalid`. +- `schema/envelope-shape` — `aipm.config.ts` loads and parses strictly against the envelope + schema. Fires on _any_ failure to resolve the envelope, not only a schema violation: a missing + `aipm.config.ts` in a plugin-shaped directory, a file that cannot be imported (syntax error, no + usable default export), or a file that imports but violates the schema (#101). The first two + carry the config loader's own message; the third expands into one diagnostic per Zod issue. In + every case this mirrors `validate()`'s `envelope-invalid` for the same tree — `lint`, `validate` + and `build` must agree rather than one staying silent; legacy code `envelope-invalid`. - `schema/target-conformance` — every target manifest in a plugin's envelope parses against that target's current schema; legacy codes `schema-invalid`, plus the Open Plugins-specific `metadata-dir-isolation` and `open-plugins-conformance` (soft, advisory-only) findings the same diff --git a/packages/core/src/lint/engine.test.ts b/packages/core/src/lint/engine.test.ts index f817b85..83fb8b1 100644 --- a/packages/core/src/lint/engine.test.ts +++ b/packages/core/src/lint/engine.test.ts @@ -266,3 +266,60 @@ describe('lint() — plugin-shaped repo-root subdirectory missing aipm.config.ts expect(result.diagnostics.filter((d) => d.file === 'broken')).toHaveLength(1); }); }); + +// #101, follow-on: the same lint/validate disagreement exists for an `aipm.config.ts` that is +// present but cannot be imported (syntax error, no usable default export). `runValidate` reports +// a hard `envelope-invalid` for it; `lint()` previously returned no diagnostic at all, so a +// syntax-broken envelope still got a clean bill of health from `lint`. +describe('lint() — plugin whose aipm.config.ts exists but fails to import (#101)', () => { + it('agrees with runValidate(): both report the equivalent envelope-invalid for an unparseable config', async () => { + write('plugins/broken/.claude-plugin/plugin.json', { name: 'broken', version: '0.1.0' }); + // Deliberately unparseable TypeScript — the transpile/import step throws, so the failure + // never reaches the envelope schema and is not a ZodError. + write('plugins/broken/aipm.config.ts', 'export default {{{ not valid typescript\n'); + + const [lintResult, validateResult] = await Promise.all([lint(repoRoot), runValidate(repoRoot)]); + + const envelopeFindings = validateResult.findings.filter((f) => f.code === 'envelope-invalid'); + expect(envelopeFindings).toHaveLength(1); + expect(envelopeFindings[0]?.plugin).toBe('broken'); + expect(envelopeFindings[0]?.severity).toBe('hard'); + expect(validateResult.passed).toBe(false); + + const lintEnvelopeDiagnostics = lintResult.diagnostics.filter( + (d) => d.legacyCode === 'envelope-invalid', + ); + expect(lintEnvelopeDiagnostics).toHaveLength(1); + expect(lintEnvelopeDiagnostics[0]).toMatchObject({ + ruleId: 'schema/envelope-shape', + category: 'schema', + severity: 'error', + file: 'broken', + }); + // Byte-identical to validate's: both take the ConfigLoadError's own message. + expect(lintEnvelopeDiagnostics[0]?.message).toBe(envelopeFindings[0]?.message); + }); + + it('still reports the schema-violation case as per-issue envelope-invalid diagnostics', async () => { + // A config that imports cleanly but violates the envelope schema stays on the ZodError path, + // which expands into one diagnostic per issue rather than a single loader-message diagnostic. + write('plugins/broken/.claude-plugin/plugin.json', { name: 'broken', version: '0.1.0' }); + write( + 'plugins/broken/aipm.config.ts', + "import { defineConfig } from '@ai-plugin-marketplace/core';\n" + + "export default defineConfig({ version: 'not-semver', targets: ['not-a-target'] });\n", + ); + + const result = await lint(repoRoot); + + const envelopeDiagnostics = result.diagnostics.filter( + (d) => d.legacyCode === 'envelope-invalid', + ); + expect(envelopeDiagnostics.length).toBeGreaterThan(0); + for (const d of envelopeDiagnostics) { + expect(d.ruleId).toBe('schema/envelope-shape'); + expect(d.severity).toBe('error'); + expect(d.message).toMatch(/^Invalid aipm\.config: \[/); + } + }); +}); diff --git a/packages/core/src/lint/engine.ts b/packages/core/src/lint/engine.ts index 56c2a3d..203ce95 100644 --- a/packages/core/src/lint/engine.ts +++ b/packages/core/src/lint/engine.ts @@ -74,8 +74,9 @@ export async function lint(targetPath: string, options?: LintOptions): Promise { - const configPath = path.join(ctx.pluginDir, AIPM_CONFIG_FILENAME); const pluginName = path.basename(ctx.pluginDir); - if (!fs.existsSync(configPath)) { - // Mirrors validate()'s `envelope-invalid` finding for the identical failure (#91's - // discovery path surfaces a plugin-shaped, config-less directory; without this, lint stayed - // silent while validate/build both flagged it — #101). loadPluginConfig throws the same - // ConfigLoadError message for this case; reproduce it directly here rather than calling - // loadPluginConfig only to immediately catch it, since this path never reaches the cache. - const message = `No ${AIPM_CONFIG_FILENAME} found in ${ctx.pluginDir}. Every plugin must declare a support envelope (spec §6.1).`; - return [ - findingToDiagnostic( - { severity: 'hard', code: 'envelope-invalid', plugin: pluginName, message }, - RULE_ID, - 'schema', - docsUrlFor(RULE_ID), - ), - ]; - } try { // A cache hit here (already validated by an earlier call in this invocation) means the // envelope is known-valid — nothing further to report. @@ -75,9 +54,18 @@ export const envelopeShapeRule: Rule = { const findings = zodEnvelopeIssuesToFindings(err.cause.issues, pluginName); return findings.map((f) => findingToDiagnostic(f, RULE_ID, 'schema', docsUrlFor(RULE_ID))); } - // Import failure (syntax error, etc.) is reported by load-config's own ConfigLoadError - // path in validate()'s orchestration; nothing further to add here. - return []; + // Missing file, import/syntax failure, or an unexpected throw. Reported exactly as + // runValidate()'s envelope-load catch reports it — same code, same severity, and the + // loader's own message verbatim, so lint and validate cannot drift apart. + const message = err instanceof Error ? err.message : String(err); + return [ + findingToDiagnostic( + { severity: 'hard', code: 'envelope-invalid', plugin: pluginName, message }, + RULE_ID, + 'schema', + docsUrlFor(RULE_ID), + ), + ]; } }, };