diff --git a/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts b/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts index 4a6f60d..8d60261 100644 --- a/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts +++ b/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts @@ -262,3 +262,74 @@ describe('charter doctor --mcp (MCP wiring detection)', () => { expect(exitCode).toBe(EXIT_CODE.SUCCESS); }); }); + +describe('charter doctor packages check (#90)', () => { + async function runPackagesDoctor(configPkg: unknown): Promise<{ exitCode: number; output: DoctorOutput }> { + const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'charter-doctor-pkgs-')); + tempDirs.push(tmp); + process.chdir(tmp); + execFileSync('git', ['init', '-q'], { cwd: tmp, stdio: 'ignore' }); + + fs.mkdirSync(path.join(tmp, '.ai'), { recursive: true }); + fs.mkdirSync(path.join(tmp, '.charter'), { recursive: true }); + + fs.writeFileSync(path.join(tmp, '.ai', 'manifest.adf'), 'ADF: 0.1\n\nšŸ“¦ DEFAULT_LOAD:\n - core.adf\n - state.adf\n'); + fs.writeFileSync(path.join(tmp, '.ai', 'core.adf'), 'ADF: 0.1\n\nšŸ“ RULES:\n - Example\n'); + fs.writeFileSync(path.join(tmp, '.ai', 'state.adf'), 'ADF: 0.1\n\nšŸ“‹ STATE:\n - CURRENT: test\n'); + fs.writeFileSync(path.join(tmp, '.charter', 'config.json'), JSON.stringify({ packages: configPkg }, null, 2)); + + const logs: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...msgs: unknown[]) => { + logs.push(msgs.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let exitCode: number; + try { + exitCode = await doctorCommand(ciOptions, ['--adf-only']); + } finally { + spy.mockRestore(); + } + return { exitCode, output: JSON.parse(logs.join('\n').trim()) as DoctorOutput }; + } + + it('no packages check emitted when config.packages is absent', async () => { + const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'charter-doctor-pkgs-absent-')); + tempDirs.push(tmp); + process.chdir(tmp); + execFileSync('git', ['init', '-q'], { cwd: tmp, stdio: 'ignore' }); + writeFixture(tmp, { srcLines: 0 }); + + const { output } = await runDoctor(['--adf-only']); + const check = output.checks.find(c => c.name === 'packages'); + expect(check).toBeUndefined(); + }); + + it('PASS when all enabled packages are resolvable', async () => { + // 'path' is a built-in module — always resolvable + const { exitCode, output } = await runPackagesDoctor({ path: { enabled: true } }); + const check = output.checks.find(c => c.name === 'packages'); + expect(check?.status).toBe('PASS'); + expect(check?.details).toContain('1 enabled package(s)'); + expect(exitCode).toBe(EXIT_CODE.SUCCESS); + }); + + it('WARN when an enabled package is not installed', async () => { + const { exitCode, output } = await runPackagesDoctor({ + '@some-org/definitely-not-installed-pkg-12345': { enabled: true }, + }); + const check = output.checks.find(c => c.name === 'packages'); + expect(check?.status).toBe('WARN'); + expect(check?.details).toContain('@some-org/definitely-not-installed-pkg-12345'); + expect(exitCode).toBe(EXIT_CODE.POLICY_VIOLATION); + }); + + it('skips disabled packages in the check', async () => { + const { exitCode, output } = await runPackagesDoctor({ + '@some-org/definitely-not-installed-pkg-12345': { enabled: false }, + }); + const check = output.checks.find(c => c.name === 'packages'); + // disabled → no check emitted (no enabled entries) + expect(check).toBeUndefined(); + expect(exitCode).toBe(EXIT_CODE.SUCCESS); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 8259ecb..65a5033 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -457,6 +457,28 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P }); } + // Package ecosystem check (#90): verify enabled packages are installed. + // Charter does not maintain a hard-coded registry — it only checks that + // packages listed in config.packages are resolvable in node_modules. + const pkgEntries = Object.entries(config.packages ?? {}).filter(([, v]) => v.enabled); + if (pkgEntries.length > 0) { + const missing: string[] = []; + for (const [pkgName] of pkgEntries) { + try { + require.resolve(pkgName, { paths: [process.cwd()] }); + } catch { + missing.push(pkgName); + } + } + checks.push({ + name: 'packages', + status: missing.length > 0 ? 'WARN' : 'PASS', + details: missing.length > 0 + ? `Enabled package(s) not installed: ${missing.join(', ')}. Run: npm install ${missing.join(' ')}` + : `${pkgEntries.length} enabled package(s) installed.`, + }); + } + const hasWarn = checks.some((check) => check.status === 'WARN'); const result: DoctorResult = { status: hasWarn ? 'WARN' : 'PASS', diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 1dbedfc..629e63b 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -111,6 +111,27 @@ export interface CharterConfig { /** Ordered budget rules; the first pattern that matches a file applies. */ paths?: LocBudgetRule[]; }; + + /** + * Ecosystem packages that participate in Charter's orchestration (#90). + * + * Keys are npm package names. Each entry opts the package in and carries + * its project-specific config (validated at runtime by the package's own + * CharterPackageDescriptor.configSchema — Charter does not know the shape). + * + * Example: + * ```json + * { + * "packages": { + * "@stackbilt/llm-providers": { + * "enabled": true, + * "config": { "primary": "cloudflare", "fallback": ["groq"] } + * } + * } + * } + * ``` + */ + packages?: Record; } const DEFAULT_CONFIG: CharterConfig = { @@ -197,6 +218,7 @@ export function loadConfig(configPath: string): CharterConfig { }, ontology: parsed.ontology, locBudgets: parsed.locBudgets, + packages: parsed.packages, }; } catch (err) { console.warn(`Warning: Failed to parse ${configFile}, using defaults`); diff --git a/packages/types/src/__tests__/package-descriptor.test.ts b/packages/types/src/__tests__/package-descriptor.test.ts new file mode 100644 index 0000000..eb3d3ec --- /dev/null +++ b/packages/types/src/__tests__/package-descriptor.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import type { CharterPackageDescriptor, PackageDoctorCheck, SchemaValidator } from '../index'; + +// --------------------------------------------------------------------------- +// Structural type tests — verify the interface can be implemented +// --------------------------------------------------------------------------- + +describe('CharterPackageDescriptor interface', () => { + it('can be implemented with minimal required fields', () => { + const mockSchema: SchemaValidator<{ primary: string }> = { + parse(input: unknown) { + if (typeof input !== 'object' || input === null || !('primary' in input)) { + throw new Error('invalid'); + } + return input as { primary: string }; + }, + safeParse(input: unknown) { + try { + return { success: true as const, data: this.parse(input) }; + } catch (error) { + return { success: false as const, error }; + } + }, + }; + + const descriptor: CharterPackageDescriptor<{ primary: string }> = { + name: '@stackbilt/llm-providers', + description: 'Multi-LLM failover with circuit breakers', + npmPackage: '@stackbilt/llm-providers', + configSchema: mockSchema, + }; + + expect(descriptor.name).toBe('@stackbilt/llm-providers'); + expect(descriptor.description).toContain('LLM'); + expect(descriptor.npmPackage).toBe('@stackbilt/llm-providers'); + expect(descriptor.scaffoldTemplates).toBeUndefined(); + expect(descriptor.adfModule).toBeUndefined(); + expect(descriptor.wranglerBindings).toBeUndefined(); + }); + + it('can be implemented with all optional fields', () => { + const check: PackageDoctorCheck = { + name: 'AI binding', + run: async (_config, _repoPath) => null, + }; + + const mockSchema: SchemaValidator = { + parse: (i: unknown) => i, + safeParse: (i: unknown) => ({ success: true as const, data: i }), + }; + + const descriptor: CharterPackageDescriptor = { + name: '@stackbilt/contracts', + description: 'ODD contract ontology', + npmPackage: '@stackbilt/contracts', + configSchema: mockSchema, + scaffoldTemplates: ['templates/worker.ts.tpl'], + adfModule: 'adf/contracts.adf', + doctorChecks: [check], + wranglerBindings: ['AI', 'CONTRACTS_KV'], + }; + + expect(descriptor.scaffoldTemplates).toHaveLength(1); + expect(descriptor.doctorChecks).toHaveLength(1); + expect(descriptor.wranglerBindings).toContain('AI'); + }); + + it('SchemaValidator.safeParse returns typed result', () => { + const schema: SchemaValidator = { + parse(input: unknown) { + if (typeof input !== 'number') throw new Error('not a number'); + return input; + }, + safeParse(input: unknown) { + try { + return { success: true as const, data: this.parse(input) }; + } catch (error) { + return { success: false as const, error }; + } + }, + }; + + const ok = schema.safeParse(42); + expect(ok.success).toBe(true); + if (ok.success) expect(ok.data).toBe(42); + + const fail = schema.safeParse('not-a-number'); + expect(fail.success).toBe(false); + }); + + it('PackageDoctorCheck.run returns null on pass', async () => { + const check: PackageDoctorCheck = { + name: 'wrangler AI binding', + run: async (_config, _repoPath) => { + // pretend the binding exists + return null; + }, + }; + + const result = await check.run({}, '/some/repo'); + expect(result).toBeNull(); + }); + + it('PackageDoctorCheck.run returns a message on failure', async () => { + const check: PackageDoctorCheck = { + name: 'wrangler AI binding', + run: async (_config, _repoPath) => { + return 'AI binding not found in wrangler.toml'; + }, + }; + + const result = await check.run({}, '/some/repo'); + expect(typeof result).toBe('string'); + expect(result).toContain('AI binding'); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ec33761..756a3a6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -571,3 +571,79 @@ export interface GovernanceGate; } + +// ============================================================================ +// Package Ecosystem Contract (#90) +// +// Decentralized extension point: each ecosystem package that wants to +// participate in charter init / scaffold / doctor / adf populate ships a +// CharterPackageDescriptor in its own npm package. Charter loads descriptors +// at runtime — it never hard-codes a registry of known packages here. +// ============================================================================ + +/** + * Minimal schema-validator shape that CharterPackageDescriptor.configSchema must + * satisfy. Structurally compatible with z.ZodType (Zod) so callers can pass + * a Zod schema directly without importing Zod into @stackbilt/types. + */ +export interface SchemaValidator { + parse(input: unknown): T; + safeParse(input: unknown): { success: true; data: T } | { success: false; error: unknown }; +} + +/** + * Doctor check registered by a package. Charter runs these during `charter doctor` + * for every enabled package. + */ +export interface PackageDoctorCheck { + /** Short display name shown in doctor output. */ + name: string; + /** + * Returns null if the check passes, or a human-readable failure message. + * Receives the validated package-specific config object. + */ + run(config: unknown, repoPath: string): Promise; +} + +/** + * Descriptor that an ecosystem package ships to participate in Charter's + * orchestration (init, scaffold, doctor, adf populate, serve). + * + * Descriptors are authored in the package repo — NOT in Charter. Charter + * loads them by resolving `require('/charter-descriptor')` at + * runtime (when the package is installed in the target project). + * + * @typeParam C - Validated config shape. Must match `configSchema`. + */ +export interface CharterPackageDescriptor { + /** Canonical npm package name. Used for installation guidance and deduplication. */ + readonly name: string; + /** One-line description shown in `charter init` package selection UI. */ + readonly description: string; + /** npm package name (may differ from `name` if scoped). */ + readonly npmPackage: string; + /** + * Schema validator for the per-project config block stored in + * `.charter/config.json` under `packages[name].config`. + * Use a Zod schema (structurally satisfies SchemaValidator). + */ + readonly configSchema: SchemaValidator; + /** + * Template file paths to generate during `charter scaffold`. + * Relative to the package's own directory — Charter resolves them at + * runtime via `require.resolve`. + */ + readonly scaffoldTemplates?: readonly string[]; + /** + * Path to an ADF module file within the package to inject during + * `charter adf populate`. Relative to the package root. + */ + readonly adfModule?: string; + /** Doctor checks this package contributes. */ + readonly doctorChecks?: readonly PackageDoctorCheck[]; + /** + * wrangler.toml binding names this package requires (e.g. `["AI", "MY_KV"]`). + * Charter reports missing bindings during `charter doctor`. + */ + readonly wranglerBindings?: readonly string[]; +}