Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { enabled: boolean; config?: unknown }>;
}

const DEFAULT_CONFIG: CharterConfig = {
Expand Down Expand Up @@ -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`);
Expand Down
116 changes: 116 additions & 0 deletions packages/types/src/__tests__/package-descriptor.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> = {
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');
});
});
76 changes: 76 additions & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,3 +571,79 @@ export interface GovernanceGate<Context, P extends GovernanceProposal = Governan
*/
commit(proposal: P, decision: GovernanceDecision): Promise<GovernanceReceipt>;
}

// ============================================================================
// 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<T> (Zod) so callers can pass
* a Zod schema directly without importing Zod into @stackbilt/types.
*/
export interface SchemaValidator<T = unknown> {
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<string | null>;
}

/**
* 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('<npmPackage>/charter-descriptor')` at
* runtime (when the package is installed in the target project).
*
* @typeParam C - Validated config shape. Must match `configSchema`.
*/
export interface CharterPackageDescriptor<C = unknown> {
/** 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<C>).
*/
readonly configSchema: SchemaValidator<C>;
/**
* 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[];
}