diff --git a/.changeset/lint-missing-config-envelope-invalid.md b/.changeset/lint-missing-config-envelope-invalid.md new file mode 100644 index 0000000..f5fc9ab --- /dev/null +++ b/.changeset/lint-missing-config-envelope-invalid.md @@ -0,0 +1,12 @@ +--- +'@ai-plugin-marketplace/core': patch +--- + +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 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 e8aebf6..6d8e5e3 100644 --- a/docs/specs/lint-engine.md +++ b/docs/specs/lint-engine.md @@ -213,8 +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; 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 e7af43c..83fb8b1 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,105 @@ 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); + }); +}); + +// #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); - if (!fs.existsSync(configPath)) return []; const pluginName = path.basename(ctx.pluginDir); try { // A cache hit here (already validated by an earlier call in this invocation) means the @@ -58,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), + ), + ]; } }, };