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
2 changes: 1 addition & 1 deletion packages/adf/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/adf",
"sideEffects": false,
"version": "1.8.0",
"version": "1.9.0",
"description": "ADF (Attention-Directed Format) — AST-backed context format for AI agents",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
6 changes: 6 additions & 0 deletions packages/adf/src/__tests__/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ describe('bundleModules', () => {
}
});

it('populates documents with each resolved module\'s parsed doc, keyed by path', () => {
const result = bundleModules('/ai', ['core.adf', 'state.adf'], readFile);
expect(Object.keys(result.documents ?? {})).toEqual(['core.adf', 'state.adf']);
expect(result.documents?.['core.adf'].sections.some(s => s.key === 'CONSTRAINTS')).toBe(true);
});

it('returns token estimate > 0', () => {
const result = bundleModules('/ai', ['core.adf'], readFile);
expect(result.tokenEstimate).toBeGreaterThan(0);
Expand Down
84 changes: 84 additions & 0 deletions packages/adf/src/__tests__/evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,88 @@ describe('evaluateEvidence', () => {
const report = evaluateEvidence(makeBundleResult());
expect(report.staleBaselines).toEqual([]);
});

it('attributes constraints to their owning module when bundle.documents is present', () => {
const bundle = makeBundleResult({
resolvedModules: ['frontend.adf', 'backend.adf'],
documents: {
'frontend.adf': {
version: '0.1',
sections: [
{
key: 'METRICS',
decoration: null,
content: { type: 'metric', entries: [{ key: 'entry_loc', value: 300, ceiling: 200, unit: 'lines' }] },
},
],
},
'backend.adf': {
version: '0.1',
sections: [
{
key: 'METRICS',
decoration: null,
content: { type: 'metric', entries: [{ key: 'handler_loc', value: 50, ceiling: 200, unit: 'lines' }] },
},
],
},
},
});
const report = evaluateEvidence(bundle);
expect(report.constraints).toHaveLength(2);
const failing = report.constraints.find((c) => c.metric === 'entry_loc');
expect(failing?.module).toBe('frontend.adf');
expect(failing?.status).toBe('fail');
const passing = report.constraints.find((c) => c.metric === 'handler_loc');
expect(passing?.module).toBe('backend.adf');
expect(passing?.status).toBe('pass');
expect(report.failCount).toBe(1);
expect(report.allPassing).toBe(false);
});

it('falls back to unattributed merged-document validation when bundle.documents is absent', () => {
const report = evaluateEvidence(
makeBundleResult({
mergedDocument: {
version: '0.1',
sections: [
{
key: 'METRICS',
decoration: null,
content: { type: 'metric', entries: [{ key: 'loc', value: 100, ceiling: 200, unit: 'lines' }] },
},
],
},
}),
);
expect(report.constraints).toHaveLength(1);
expect(report.constraints[0].module).toBeUndefined();
});

it('falls back to merged-document validation when documents is present but missing an entry for a resolved module', () => {
// `documents: {}` is truthy but incomplete -- must not be treated the
// same as "per-module data available," or a resolved module with no
// matching document would silently produce zero constraints instead of
// falling back.
const report = evaluateEvidence(
makeBundleResult({
resolvedModules: ['core.adf'],
documents: {},
mergedDocument: {
version: '0.1',
sections: [
{
key: 'METRICS',
decoration: null,
content: { type: 'metric', entries: [{ key: 'loc', value: 250, ceiling: 200, unit: 'lines' }] },
},
],
},
}),
);
expect(report.constraints).toHaveLength(1);
expect(report.constraints[0].status).toBe('fail');
expect(report.constraints[0].module).toBeUndefined();
expect(report.allPassing).toBe(false);
});
});
10 changes: 7 additions & 3 deletions packages/adf/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function bundleModules(
const manifest = preloadedManifest ?? loadAndParseManifest(basePath, readFile);
const triggerMatches = buildTriggerReport(manifest, modulePaths, taskKeywords);

const documents: AdfDocument[] = [];
const documentsByPath: Record<string, AdfDocument> = {};
const perModuleTokens: Record<string, number> = {};
const advisoryOnlyModules: string[] = [];
const defaultLoadSet = new Set(manifest.defaultLoad);
Expand All @@ -54,7 +54,7 @@ export function bundleModules(
throw new AdfBundleError(`Module not found: ${modPath}`, modPath);
}
const doc = parseAdf(content);
documents.push(doc);
documentsByPath[modPath] = doc;
perModuleTokens[modPath] = estimateTokens(doc);

// Flag on-demand modules with no load-bearing sections
Expand All @@ -66,7 +66,10 @@ export function bundleModules(
}
}

const merged = mergeDocuments(documents);
// Module paths are never integer-index-like strings, so Object.values
// preserves insertion order — safe to derive the merge input from
// documentsByPath instead of maintaining a second parallel array.
const merged = mergeDocuments(Object.values(documentsByPath));
const tokenEstimate = estimateTokens(merged);
const tokenBudget = manifest.tokenBudget ?? null;
const tokenUtilization = tokenBudget !== null ? tokenEstimate / tokenBudget : null;
Expand Down Expand Up @@ -103,6 +106,7 @@ export function bundleModules(
triggerMatches,
unmatchedModules,
advisoryOnlyModules,
documents: documentsByPath,
};
}

Expand Down
56 changes: 53 additions & 3 deletions packages/adf/src/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
import type {
AdfDocument,
BundleResult,
ConstraintResult,
EvidenceResult,
} from './types';
import { validateConstraints } from './validator';
import { computeConstraints, computeWeightSummary } from './validator';

// ============================================================================
// Types
Expand Down Expand Up @@ -58,15 +59,30 @@ export function evaluateEvidence(
context?: Record<string, number>,
staleThreshold?: number,
): EvidenceReport {
const evidence = validateConstraints(bundle.mergedDocument, context);
// When per-module documents are available (the real bundleModules() path),
// validate each one separately so ConstraintResults are attributed back to
// their owning module. Falls back to the merged document (unattributed,
// matching pre-attribution behavior) for hand-built BundleResults that
// don't populate `documents` — checked for genuine full coverage, not bare
// truthiness, since `documents: {}` is truthy but has nothing to validate.
const constraints = hasCompleteDocuments(bundle)
? gatherPerModuleConstraints(bundle.documents, bundle.resolvedModules, context)
: computeConstraints(bundle.mergedDocument, context);
const failCount = constraints.filter((c) => c.status === 'fail').length;
const warnCount = constraints.filter((c) => c.status === 'warn').length;

const staleBaselines = detectStaleBaselines(
bundle.mergedDocument,
context,
staleThreshold ?? 1.2,
);

return {
...evidence,
constraints,
weightSummary: computeWeightSummary(bundle.mergedDocument),
allPassing: failCount === 0,
failCount,
warnCount,
tokenEstimate: bundle.tokenEstimate,
tokenBudget: bundle.tokenBudget,
tokenUtilization: bundle.tokenUtilization,
Expand All @@ -77,6 +93,40 @@ export function evaluateEvidence(
};
}

/**
* True only when `documents` has a parsed document for every resolved
* module — not merely present. A bare truthiness check on `bundle.documents`
* would treat `{}` (present but empty/incomplete) as "per-module data
* available," silently validating nothing for any module missing a document
* instead of falling back to the merged-document path.
*/
function hasCompleteDocuments(
bundle: BundleResult,
): bundle is BundleResult & { documents: Record<string, AdfDocument> } {
const documents = bundle.documents;
if (documents === undefined) return false;
return bundle.resolvedModules.every((modPath) => modPath in documents);
}

/**
* Validate each resolved module's document separately and tag results with
* their module path, instead of validating the merged document where module
* identity is already lost.
*/
function gatherPerModuleConstraints(
documents: Record<string, AdfDocument>,
resolvedModules: string[],
context?: Record<string, number>,
): ConstraintResult[] {
const constraints: ConstraintResult[] = [];
for (const modPath of resolvedModules) {
const doc = documents[modPath];
if (!doc) continue;
constraints.push(...computeConstraints(doc, context, modPath));
}
return constraints;
}

// ============================================================================
// Stale Baseline Detection
// ============================================================================
Expand Down
7 changes: 7 additions & 0 deletions packages/adf/src/types/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ export interface BundleResult {
}>;
unmatchedModules: string[];
advisoryOnlyModules: string[];
/**
* Parsed document for each resolved module, keyed by module path. Populated
* by `bundleModules()` (absent on hand-built BundleResults, e.g. in tests)
* so `evaluateEvidence` can validate per-module and attribute constraint
* results back to their owning module instead of only the merged document.
*/
documents?: Record<string, AdfDocument>;
}
2 changes: 2 additions & 0 deletions packages/adf/src/types/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface ConstraintResult {
status: ConstraintStatus;
message: string;
source: 'metric' | 'context';
/** Path of the `.ai/*.adf` module this constraint was checked against, when known. */
module?: string;
}

export interface WeightSummary {
Expand Down
40 changes: 30 additions & 10 deletions packages/adf/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,39 @@ import type {
*
* @param doc - Parsed ADF document
* @param context - Optional external metric overrides (e.g., actual LOC counts)
* @param module - Path of the source module this document was loaded from, when known.
* Stamped onto each ConstraintResult for failure attribution (e.g. `charter adf suggest`).
*/
export function validateConstraints(
doc: AdfDocument,
context?: Record<string, number>,
module?: string,
): EvidenceResult {
const constraints = computeConstraints(doc, context, module);
const failCount = constraints.filter(c => c.status === 'fail').length;
const warnCount = constraints.filter(c => c.status === 'warn').length;

return {
constraints,
weightSummary: computeWeightSummary(doc),
allPassing: failCount === 0,
failCount,
warnCount,
};
}

/**
* Compute per-entry ConstraintResults for a single document's METRICS
* sections, without the aggregate weightSummary/failCount/warnCount wrapper.
* Shared by validateConstraints and evidence.ts's per-module attribution
* path, so validating N modules doesn't also compute (and discard) N
* redundant weight summaries and pass/fail counts.
*/
export function computeConstraints(
doc: AdfDocument,
context?: Record<string, number>,
module?: string,
): ConstraintResult[] {
const constraints: ConstraintResult[] = [];

for (const section of doc.sections) {
Expand All @@ -48,20 +76,12 @@ export function validateConstraints(
status,
message: `${entry.key}: ${value} / ${entry.ceiling} [${entry.unit}] -- ${statusLabel}`,
source: hasContext ? 'context' : 'metric',
...(module !== undefined ? { module } : {}),
});
}
}

const failCount = constraints.filter(c => c.status === 'fail').length;
const warnCount = constraints.filter(c => c.status === 'warn').length;

return {
constraints,
weightSummary: computeWeightSummary(doc),
allPassing: failCount === 0,
failCount,
warnCount,
};
return constraints;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@stackbilt/cli",
"sideEffects": false,
"version": "1.8.0",
"version": "1.9.0",
"description": "Charter CLI — repo-level governance toolkit",
"bin": {
"charter": "./dist/bin.js"
Expand Down
Loading