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
32 changes: 32 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
107 changes: 107 additions & 0 deletions packages/adf/src/__tests__/loc-budget.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
1 change: 1 addition & 0 deletions packages/adf/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
113 changes: 113 additions & 0 deletions packages/adf/src/loc-budget.ts
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions packages/adf/src/types/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading