diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3bf54e2..b6c97f7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -128,6 +128,38 @@ npx charter doctor --ci --format json # CI mode: exit 1 on warnings - `--adf-only` — run only ADF readiness checks, skip Charter config validation - `--ci` — non-interactive, exits with policy violation code on warnings +#### Source LOC budgets (god-object drift) + +The ADF `entry_loc` metric only caps the single entry file. To stop *other* +runtime source files from quietly growing into god-objects, declare per-path LOC +budgets in `.charter/config.json`. `charter doctor` then measures matching files +and reports them: + +```jsonc +{ + "locBudgets": { + "defaultWarn": 300, + "defaultFail": 600, + "paths": [ + { "pattern": "src/index.ts", "warn": 200, "fail": 500, "reason": "entry should stay thin" }, + { "pattern": "src/**", "warn": 300, "fail": 600 } + ] + } +} +``` + +- **Patterns** match repo-relative paths: `*` within a single path segment, `**` + across segments, everything else literal. The first matching rule wins, so list + more specific patterns first. Per-rule `warn`/`fail` fall back to + `defaultWarn`/`defaultFail`. +- **Enforcement is opt-in and graduated.** A file *over its `fail` ceiling* is a + `WARN` doctor check — under `--ci` this exits non-zero, failing the build. A + file *over `warn` but within `fail`* is advisory (`INFO`) and never fails CI. + Set `"enabled": false` to keep a config block without enforcing it. +- **No budgets configured?** `doctor` emits a soft, non-blocking `INFO` nudge so + teams know `entry_loc` alone leaves other files uncovered — it never fails an + existing repo's CI on upgrade. + ### charter why Prints a quick explanation of Charter's governance value and adoption ROI. diff --git a/packages/adf/src/__tests__/loc-budget.test.ts b/packages/adf/src/__tests__/loc-budget.test.ts new file mode 100644 index 0000000..09ca528 --- /dev/null +++ b/packages/adf/src/__tests__/loc-budget.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateLocBudgets, resolveBudgetStatus, matchPath } from '../loc-budget'; +import type { LocBudgetRule } from '../types'; + +describe('resolveBudgetStatus', () => { + it('fails when lines strictly exceed the fail ceiling', () => { + expect(resolveBudgetStatus(501, 300, 500)).toBe('fail'); + }); + + it('does not fail at the fail ceiling (boundary is not exceeded)', () => { + expect(resolveBudgetStatus(500, 300, 500)).toBe('warn'); + }); + + it('warns when lines exceed warn but not fail', () => { + expect(resolveBudgetStatus(400, 300, 500)).toBe('warn'); + }); + + it('passes when below all ceilings', () => { + expect(resolveBudgetStatus(100, 300, 500)).toBe('pass'); + }); + + it('treats null ceilings as unset', () => { + expect(resolveBudgetStatus(10_000, null, null)).toBe('pass'); + expect(resolveBudgetStatus(10_000, null, 500)).toBe('fail'); + expect(resolveBudgetStatus(400, 300, null)).toBe('warn'); + }); +}); + +describe('matchPath', () => { + it('matches an exact path', () => { + expect(matchPath('src/index.ts', 'src/index.ts')).toBe(true); + expect(matchPath('src/other.ts', 'src/index.ts')).toBe(false); + }); + + it('* matches within a single segment only', () => { + expect(matchPath('src/index.ts', 'src/*.ts')).toBe(true); + expect(matchPath('src/a/index.ts', 'src/*.ts')).toBe(false); + }); + + it('** matches across segments, including zero', () => { + expect(matchPath('src/index.ts', 'src/**/*.ts')).toBe(true); + expect(matchPath('src/a/b/index.ts', 'src/**/*.ts')).toBe(true); + expect(matchPath('lib/index.ts', 'src/**/*.ts')).toBe(false); + }); + + it('bare ** matches anything under a prefix', () => { + expect(matchPath('packages/cli/src/x.ts', 'packages/**')).toBe(true); + }); + + it('normalizes Windows backslashes', () => { + expect(matchPath('src\\commands\\run.ts', 'src/**/*.ts')).toBe(true); + }); + + it('escapes regex-special characters in the literal portion', () => { + expect(matchPath('src/a.ts', 'src/a.ts')).toBe(true); + expect(matchPath('src/axts', 'src/a.ts')).toBe(false); // '.' is literal, not "any char" + }); +}); + +describe('evaluateLocBudgets', () => { + const rules: LocBudgetRule[] = [ + { pattern: 'src/index.ts', warn: 300, fail: 500, reason: 'entry should stay thin' }, + { pattern: 'src/**/*.ts', warn: 200, fail: 400 }, + ]; + + it('applies the first matching rule (most specific listed first)', () => { + const results = evaluateLocBudgets([{ path: 'src/index.ts', lines: 450 }], rules); + expect(results).toHaveLength(1); + expect(results[0].pattern).toBe('src/index.ts'); + expect(results[0].status).toBe('warn'); // 450 > 300 warn, <= 500 fail + expect(results[0].reason).toBe('entry should stay thin'); + }); + + it('flags a god-object that exceeds the fail ceiling', () => { + const results = evaluateLocBudgets([{ path: 'src/index.ts', lines: 1600 }], rules); + expect(results[0].status).toBe('fail'); + expect(results[0].message).toContain('1600 lines'); + expect(results[0].message).toContain('FAIL'); + }); + + it('omits files that match no rule', () => { + const results = evaluateLocBudgets([{ path: 'README.md', lines: 9000 }], rules); + expect(results).toHaveLength(0); + }); + + it('falls back to defaults when a rule omits ceilings', () => { + const results = evaluateLocBudgets( + [{ path: 'src/util.ts', lines: 350 }], + [{ pattern: 'src/util.ts' }], + { warn: 100, fail: 300 }, + ); + expect(results[0].warn).toBe(100); + expect(results[0].fail).toBe(300); + expect(results[0].status).toBe('fail'); // 350 > 300 default fail + }); + + it('evaluates each matched file independently', () => { + const results = evaluateLocBudgets( + [ + { path: 'src/index.ts', lines: 100 }, + { path: 'src/big/service.ts', lines: 999 }, + ], + rules, + ); + expect(results.map(r => r.status)).toEqual(['pass', 'fail']); + }); +}); diff --git a/packages/adf/src/index.ts b/packages/adf/src/index.ts index 33b382d..f115581 100644 --- a/packages/adf/src/index.ts +++ b/packages/adf/src/index.ts @@ -3,6 +3,7 @@ export { formatAdf } from './formatter'; export { applyPatches } from './patcher'; export { parseManifest, resolveModules, bundleModules } from './bundler'; export { validateConstraints, computeWeightSummary } from './validator'; +export { evaluateLocBudgets, resolveBudgetStatus, matchPath } from './loc-budget'; export { evaluateEvidence } from './evidence'; export type { EvidenceReport, StaleBaselineWarning } from './evidence'; export { parseMarkdownSections } from './markdown-parser'; diff --git a/packages/adf/src/loc-budget.ts b/packages/adf/src/loc-budget.ts new file mode 100644 index 0000000..f94d6df --- /dev/null +++ b/packages/adf/src/loc-budget.ts @@ -0,0 +1,113 @@ +/** + * Per-path source LOC budget evaluation. + * + * Extends the single-ceiling `entry_loc` constraint model (see validator.ts) + * with per-path budgets carrying independent warn/fail ceilings, so teams can + * catch god-object drift across arbitrary runtime source files — not just the + * one entry file. Pure core: callers supply already-measured line counts; this + * module does matching + status resolution only (no filesystem access). + */ + +import type { ConstraintStatus, LocBudgetRule, LocBudgetResult } from './types'; + +/** + * Resolve a budget status for a measured line count against warn/fail ceilings. + * + * Mirrors validator.ts `resolveStatus` semantics (a value strictly *exceeding* + * a ceiling breaches it), generalized to two ceilings: + * - lines > fail → fail + * - lines > warn → warn + * - otherwise → pass + * + * A null ceiling is treated as "unset" (never breached at that level). + */ +export function resolveBudgetStatus( + lines: number, + warn: number | null, + fail: number | null, +): ConstraintStatus { + if (fail !== null && lines > fail) return 'fail'; + if (warn !== null && lines > warn) return 'warn'; + return 'pass'; +} + +/** + * Match a repo-relative file path against a glob pattern. + * + * Minimal, dependency-free glob: `**` matches across path segments (including + * none), `*` matches within a single segment, everything else is literal. + * Backslashes are normalized to `/` so Windows paths match POSIX patterns. + */ +export function matchPath(filePath: string, pattern: string): boolean { + return globToRegExp(pattern).test(filePath.replace(/\\/g, '/')); +} + +function globToRegExp(pattern: string): RegExp { + const p = pattern.replace(/\\/g, '/'); + let re = ''; + for (let i = 0; i < p.length; i++) { + const c = p[i]; + if (c === '*') { + if (p[i + 1] === '*') { + // `**` — match across segments. Consume a trailing slash so that + // `src/**/x` matches `src/x` (zero intervening segments) as well as + // `src/a/x`. Bare `**` (no following slash) matches anything. + if (p[i + 2] === '/') { + re += '(?:[^/]*/)*'; + i += 2; // skip second '*' and the '/' + } else { + re += '.*'; + i += 1; // skip second '*' + } + } else { + re += '[^/]*'; + } + } else if ('.+?^${}()|[]\\'.includes(c)) { + re += '\\' + c; + } else { + re += c; + } + } + return new RegExp('^' + re + '$'); +} + +/** + * Evaluate measured files against an ordered list of LOC budget rules. + * + * Each file is matched against the rules in order; the FIRST matching rule + * applies (so list more specific patterns first). Files matching no rule are + * omitted from the results. Per-rule ceilings fall back to the supplied + * defaults when unset. + * + * @param files Measured files (repo-relative path + line count). + * @param rules Ordered budget rules. + * @param defaults Optional default warn/fail ceilings applied when a rule omits one. + */ +export function evaluateLocBudgets( + files: Array<{ path: string; lines: number }>, + rules: LocBudgetRule[], + defaults?: { warn?: number; fail?: number }, +): LocBudgetResult[] { + const results: LocBudgetResult[] = []; + for (const file of files) { + const rule = rules.find(r => matchPath(file.path, r.pattern)); + if (!rule) continue; + + const warn = rule.warn ?? defaults?.warn ?? null; + const fail = rule.fail ?? defaults?.fail ?? null; + const status = resolveBudgetStatus(file.lines, warn, fail); + const ceilingLabel = `warn ${warn ?? '—'}, fail ${fail ?? '—'}`; + + results.push({ + path: file.path, + pattern: rule.pattern, + lines: file.lines, + warn, + fail, + status, + reason: rule.reason, + message: `${file.path}: ${file.lines} lines (${ceilingLabel}) -- ${status.toUpperCase()}`, + }); + } + return results; +} diff --git a/packages/adf/src/types/validation.ts b/packages/adf/src/types/validation.ts index b3b726c..d7a8377 100644 --- a/packages/adf/src/types/validation.ts +++ b/packages/adf/src/types/validation.ts @@ -44,3 +44,44 @@ export interface AdfSyncStatus { lockedHash: string | null; inSync: boolean; } + +/** + * A per-path source LOC budget rule. Lets teams cap how large runtime + * source files may grow (god-object drift) beyond the single `entry_loc` + * metric, with separate warn/fail ceilings per path pattern. + * + * Shape is intentionally a plain serializable object so a future Zod + * schema can validate it as a drop-in (see config Zod-migration direction). + */ +export interface LocBudgetRule { + /** + * Path glob matched against repo-relative file paths. Supports `*` (within a + * single path segment), `**` (across segments, including none), and literal + * text. Examples: an exact path like `src/index.ts`, or a recursive glob that + * targets every TypeScript file under `src`. + */ + pattern: string; + /** Warn ceiling: a file exceeding this (but not `fail`) yields `warn`. Falls back to the budget default when omitted. */ + warn?: number; + /** Fail ceiling: a file exceeding this yields `fail`. Falls back to the budget default when omitted. */ + fail?: number; + /** Optional human rationale for the budget, surfaced in output. */ + reason?: string; +} + +/** Result of evaluating one matched file against its LOC budget rule. */ +export interface LocBudgetResult { + /** Repo-relative path of the measured file. */ + path: string; + /** The rule pattern that matched this file. */ + pattern: string; + /** Measured line count. */ + lines: number; + /** Effective warn ceiling applied (after default fallback), or null if none. */ + warn: number | null; + /** Effective fail ceiling applied (after default fallback), or null if none. */ + fail: number | null; + status: ConstraintStatus; + reason?: string; + message: string; +} diff --git a/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts b/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts new file mode 100644 index 0000000..6fb3b67 --- /dev/null +++ b/packages/cli/src/__tests__/integration/doctor-loc-budget.test.ts @@ -0,0 +1,172 @@ +/** + * Integration tests: charter doctor source LOC budget enforcement (#186) + * + * Verifies the per-path LOC budget feature surfaces through `charter doctor`: + * - fail-ceiling breach → WARN check → CI exit 1 (POLICY_VIOLATION) + * - warn-ceiling breach → advisory INFO → CI exit 0 (does not break the build) + * - within budget → PASS + * - no coverage configured → soft INFO nudge (never WARN, never breaks CI) + * + * Runs under --adf-only (the invocation in the issue repro) against a git-init'd + * temp repo with a minimal valid .ai, so the budget check is the only variable. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CLIOptions } from '../../index'; +import { EXIT_CODE } from '../../index'; +import { doctorCommand } from '../../commands/doctor'; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(label: string): string { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `charter-doctor-loc-${label}-`)); + tempDirs.push(tmp); + return tmp; +} + +const ciOptions: CLIOptions = { + configPath: '.charter', + format: 'json', + ciMode: true, + yes: false, +}; + +interface DoctorOutput { + status: 'PASS' | 'WARN'; + checks: Array<{ name: string; status: 'PASS' | 'WARN' | 'INFO'; details: string }>; +} + +/** + * Lay down a git repo with a minimal valid .ai so the only non-PASS doctor + * check can be the LOC budget. `srcLines` source lines become a measured value + * of srcLines+1 (trailing newline; same convention as adf evidence). + */ +function writeFixture(tmp: string, opts: { srcLines: number; config?: unknown }): void { + execFileSync('git', ['init', '-q'], { cwd: tmp, stdio: 'ignore' }); + + fs.mkdirSync(path.join(tmp, '.ai'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.ai', 'manifest.adf'), `ADF: 0.1 + +📦 DEFAULT_LOAD: + - core.adf + - state.adf +`); + fs.writeFileSync(path.join(tmp, '.ai', 'core.adf'), 'ADF: 0.1\n\n📊 METRICS:\n entry_loc: 0 / 500 [lines]\n'); + fs.writeFileSync(path.join(tmp, '.ai', 'state.adf'), 'ADF: 0.1\n\n📋 STATE:\n - CURRENT: testing\n'); + + if (opts.config !== undefined) { + fs.mkdirSync(path.join(tmp, '.charter'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.charter', 'config.json'), JSON.stringify(opts.config, null, 2)); + } + + fs.mkdirSync(path.join(tmp, 'src'), { recursive: true }); + const content = Array.from({ length: opts.srcLines }, (_, i) => `// line ${i + 1}`).join('\n') + '\n'; + fs.writeFileSync(path.join(tmp, 'src', 'index.ts'), content); +} + +async function runDoctor(args: string[]): Promise<{ exitCode: number; output: DoctorOutput }> { + const logs: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...msgs: unknown[]) => { + logs.push(msgs.map(String).join(' ')); + }); + let exitCode: number; + try { + exitCode = await doctorCommand(ciOptions, args); + } finally { + spy.mockRestore(); + } + return { exitCode, output: JSON.parse(logs.join('\n').trim()) as DoctorOutput }; +} + +function budgetCheck(output: DoctorOutput) { + return output.checks.find(c => c.name === 'source loc budget'); +} + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe('charter doctor source LOC budget (integration)', () => { + const budgetConfig = { + project: 'fixture', + locBudgets: { paths: [{ pattern: 'src/index.ts', warn: 300, fail: 500 }] }, + }; + + it('fails CI (exit 1) when a file exceeds its fail ceiling', async () => { + const tmp = makeTempDir('fail'); + process.chdir(tmp); + writeFixture(tmp, { srcLines: 600, config: budgetConfig }); // measured 601 > 500 + + const { exitCode, output } = await runDoctor(['--adf-only']); + const check = budgetCheck(output); + + expect(check?.status).toBe('WARN'); + expect(check?.details).toContain('src/index.ts'); + expect(check?.details).toContain('FAIL'); + expect(exitCode).toBe(EXIT_CODE.POLICY_VIOLATION); + }); + + it('does NOT fail CI when a file only exceeds its warn ceiling (advisory INFO)', async () => { + const tmp = makeTempDir('warn'); + process.chdir(tmp); + writeFixture(tmp, { srcLines: 400, config: budgetConfig }); // measured 401: >300 warn, <=500 fail + + const { exitCode, output } = await runDoctor(['--adf-only']); + const check = budgetCheck(output); + + expect(check?.status).toBe('INFO'); + expect(check?.details).toContain('warn ceiling'); + expect(exitCode).toBe(EXIT_CODE.SUCCESS); + }); + + it('passes when the file is within budget', async () => { + const tmp = makeTempDir('pass'); + process.chdir(tmp); + writeFixture(tmp, { srcLines: 100, config: budgetConfig }); // measured 101 + + const { exitCode, output } = await runDoctor(['--adf-only']); + const check = budgetCheck(output); + + expect(check?.status).toBe('PASS'); + expect(exitCode).toBe(EXIT_CODE.SUCCESS); + }); + + it('emits a soft INFO nudge (no CI failure) when no LOC coverage is configured', async () => { + const tmp = makeTempDir('nocov'); + process.chdir(tmp); + writeFixture(tmp, { srcLines: 5000 }); // huge file, but no locBudgets and no manifest METRICS + + const { exitCode, output } = await runDoctor(['--adf-only']); + const check = budgetCheck(output); + + expect(check?.status).toBe('INFO'); + expect(check?.details).toContain('No runtime source LOC coverage'); + expect(exitCode).toBe(EXIT_CODE.SUCCESS); + }); + + it('respects enabled:false (no enforcement)', async () => { + const tmp = makeTempDir('disabled'); + process.chdir(tmp); + writeFixture(tmp, { + srcLines: 600, + config: { project: 'fixture', locBudgets: { enabled: false, paths: [{ pattern: 'src/index.ts', fail: 500 }] } }, + }); + + const { exitCode, output } = await runDoctor(['--adf-only']); + const check = budgetCheck(output); + + // Disabled → falls through to the coverage nudge, never a WARN. + expect(check?.status).toBe('INFO'); + expect(exitCode).toBe(EXIT_CODE.SUCCESS); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index a7364a8..d2c4468 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -8,8 +8,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { CLIOptions } from '../index'; import { EXIT_CODE } from '../index'; -import { loadPatterns } from '../config'; -import { parseAdf, parseManifest, stripCharterSentinels } from '@stackbilt/adf'; +import { loadPatterns, loadConfig } from '../config'; +import { parseAdf, parseManifest, stripCharterSentinels, evaluateLocBudgets, matchPath } from '@stackbilt/adf'; +import type { LocBudgetRule } from '@stackbilt/adf'; import { isGitRepo } from '../git-helpers'; import { POINTER_MARKERS } from './adf'; @@ -27,6 +28,10 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P const adfOnly = args.includes('--adf-only'); const configFile = path.join(options.configPath, 'config.json'); const inGitRepo = isGitRepo(); + const config = loadConfig(options.configPath); + // Number of files with per-file LOC measurement declared in manifest METRICS; + // set during manifest parse, used by the source LOC budget coverage check. + let manifestLocMetricCount = 0; checks.push({ name: 'git repository', @@ -95,6 +100,7 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); const manifestDoc = parseAdf(manifestContent); const manifest = parseManifest(manifestDoc); + manifestLocMetricCount = manifest.metrics.length; checks.push({ name: 'adf manifest parse', @@ -332,6 +338,53 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P } } + // Source LOC budget coverage + enforcement (#186). + // Runs regardless of --adf-only so the pre-commit/CI gate surfaces it. + const locBudgets = config.locBudgets; + const budgetRules = locBudgets?.paths ?? []; + const budgetsEnabled = !!locBudgets && locBudgets.enabled !== false && budgetRules.length > 0; + + if (budgetsEnabled) { + const measured = collectBudgetFiles('.', budgetRules); + const results = evaluateLocBudgets(measured, budgetRules, { + warn: locBudgets!.defaultWarn, + fail: locBudgets!.defaultFail, + }); + const failed = results.filter(r => r.status === 'fail'); + const warned = results.filter(r => r.status === 'warn'); + + if (failed.length > 0) { + // Over the fail ceiling → WARN (doctor fails CI on any WARN in --ci mode). + checks.push({ + name: 'source loc budget', + status: 'WARN', + details: `${failed.length} file(s) over their fail ceiling:\n ${failed.map(r => r.message).join('\n ')}`, + }); + } else if (warned.length > 0) { + // Over the warn ceiling only → advisory INFO (does not break CI). + checks.push({ + name: 'source loc budget', + status: 'INFO', + details: `${warned.length} file(s) over their warn ceiling (advisory):\n ${warned.map(r => r.message).join('\n ')}`, + }); + } else { + checks.push({ + name: 'source loc budget', + status: 'PASS', + details: `${results.length} file(s) within configured source LOC budgets.`, + }); + } + } else if (manifestLocMetricCount === 0) { + // No runtime LOC coverage from either source → soft, non-blocking nudge. + // Intentionally INFO, not WARN: doctor fails CI on any WARN, and emitting + // a warning here would break every repo that hasn't opted in yet (#186). + checks.push({ + name: 'source loc budget', + status: 'INFO', + details: 'No runtime source LOC coverage configured. Only ADF entry_loc (if declared) is enforced, so other files can grow into god-objects unchecked. Add a `locBudgets` block to .charter/config.json to set per-path warn/fail ceilings.', + }); + } + const hasWarn = checks.some((check) => check.status === 'WARN'); const result: DoctorResult = { status: hasWarn ? 'WARN' : 'PASS', @@ -355,6 +408,52 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P return EXIT_CODE.SUCCESS; } +/** + * Walk the repo and measure line counts for files matching any LOC budget rule. + * Skips the same heavy/managed directories as the security-test walk. Paths are + * returned repo-relative with forward slashes so they match POSIX-style patterns. + */ +function collectBudgetFiles( + rootPath: string, + rules: LocBudgetRule[], +): Array<{ path: string; lines: number }> { + const skipDirs = new Set(['.git', 'node_modules', 'dist', 'coverage', '.ai', '.charter', '.pnpm-store']); + const out: Array<{ path: string; lines: number }> = []; + + function walk(dir: string): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relPath = (path.relative(rootPath, fullPath) || entry.name).replace(/\\/g, '/'); + + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && rules.some(r => matchPath(relPath, r.pattern))) { + try { + const content = fs.readFileSync(fullPath, 'utf-8'); + out.push({ path: relPath, lines: content.split('\n').length }); + } catch { + // Skip unreadable files. + } + } + } + } + + walk(rootPath); + return out; +} + function findSecurityTestFiles(rootPath: string): string[] { const matches: string[] = []; const skipDirs = new Set(['.git', 'node_modules', 'dist', 'coverage', '.ai', '.charter']); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 7e156ae..1dbedfc 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { Pattern } from '@stackbilt/types'; +import type { LocBudgetRule } from '@stackbilt/adf'; // ============================================================================ // Config Types @@ -91,6 +92,25 @@ export interface CharterConfig { */ ignoreAliases?: string[]; }; + + /** + * Per-path source LOC budgets. Catches god-object drift across runtime + * source files beyond the single ADF `entry_loc` metric (see #186). + * + * Entirely opt-in: when this block is absent, Charter enforces no source + * LOC budgets and `charter doctor` only emits a soft, non-blocking + * informational nudge that no runtime LOC coverage is configured. + */ + locBudgets?: { + /** Master switch. Defaults to true when the block is present. */ + enabled?: boolean; + /** Default warn ceiling applied to rules that omit their own `warn`. */ + defaultWarn?: number; + /** Default fail ceiling applied to rules that omit their own `fail`. */ + defaultFail?: number; + /** Ordered budget rules; the first pattern that matches a file applies. */ + paths?: LocBudgetRule[]; + }; } const DEFAULT_CONFIG: CharterConfig = { @@ -176,6 +196,7 @@ export function loadConfig(configPath: string): CharterConfig { }, }, ontology: parsed.ontology, + locBudgets: parsed.locBudgets, }; } catch (err) { console.warn(`Warning: Failed to parse ${configFile}, using defaults`); @@ -194,7 +215,7 @@ export function loadPatterns(configPath: string): Pattern[] { } const patterns: Pattern[] = []; - const files = fs.readdirSync(patternsDir).filter(f => f.endsWith('.json') && f !== 'security-deny.json'); + const files = fs.readdirSync(patternsDir).filter(f => f.endsWith('.json') && f !== 'security-deny.json'); for (const file of files) { try {