feat(mft): v5.6 B1 — load-pattern accounting in manifest
Consume the v5.6 Foundation enumeration in buildManifest:
- Component-level sources: drop the coarse `kind:'plugin'` roll-up (it
double-counted skills/rules/agents already enumerated once). Kinds are now
claude-md/skill/rule/agent/output-style/mcp-server/hook.
- Every source carries loadPattern/survivesCompaction/derivationConfidence.
Rules/agents/output-styles propagate the foundation-derived values; CLAUDE.md
maps scope→kind (all cascade files always-loaded); skills are tagged on-demand
(skill-body) so the body cost does not inflate the always-loaded subtotal.
- New `summary` (always/onDemand/external/unknown {tokens,count}); the
always-loaded subtotal — "tokens that enter context every turn" — is the headline.
manifest is an environment-aware CLI → SC-6/SC-7 verify it by mode-equivalence,
not byte-equal, and it is not in SC-5. Adding fields in place keeps all snapshots
green with no regen (verified). `total` changes (de-duped) — intended correctness fix.
TOK's load-pattern column (byte-equal SC-6) is deferred to the next chunk (B2).
Tests 996→1008 (deterministic buildManifest unit test + CLI presence checks).
Self-audit A/A, scanner count unchanged at 13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62d910ed6d
commit
bb647ce35f
7 changed files with 324 additions and 66 deletions
|
|
@ -5,6 +5,8 @@ import { resolve, join, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { buildManifest } from '../../scanners/manifest.mjs';
|
||||
import { deriveLoadPattern } from '../../scanners/lib/active-config-reader.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PLUGIN_ROOT = resolve(__dirname, '../..');
|
||||
|
|
@ -64,6 +66,131 @@ describe('manifest CLI — real-config path (plugin root)', () => {
|
|||
it('meta.repoPath matches the requested path', () => {
|
||||
assert.equal(output.meta.repoPath, PLUGIN_ROOT);
|
||||
});
|
||||
|
||||
it('every source carries a load-pattern triple', () => {
|
||||
const LOAD_PATTERNS = new Set(['always', 'on-demand', 'external', 'unknown']);
|
||||
const SURVIVES = new Set(['yes', 'no', 'n/a']);
|
||||
for (const s of output.sources) {
|
||||
assert.ok(LOAD_PATTERNS.has(s.loadPattern),
|
||||
`${s.kind}:${s.name} has invalid loadPattern: ${s.loadPattern}`);
|
||||
assert.ok(SURVIVES.has(s.survivesCompaction),
|
||||
`${s.kind}:${s.name} has invalid survivesCompaction: ${s.survivesCompaction}`);
|
||||
assert.ok(typeof s.derivationConfidence === 'string' && s.derivationConfidence.length > 0,
|
||||
`${s.kind}:${s.name} missing derivationConfidence`);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits a load-pattern summary with always/on-demand/external buckets', () => {
|
||||
assert.ok(output.summary, 'expected output.summary');
|
||||
for (const bucket of ['always', 'onDemand', 'external']) {
|
||||
assert.ok(output.summary[bucket], `expected summary.${bucket}`);
|
||||
assert.equal(typeof output.summary[bucket].tokens, 'number');
|
||||
assert.equal(typeof output.summary[bucket].count, 'number');
|
||||
}
|
||||
// The bucket token sums must reconcile with the per-source loadPattern tags.
|
||||
const alwaysSum = output.sources
|
||||
.filter(s => s.loadPattern === 'always')
|
||||
.reduce((a, s) => a + s.estimated_tokens, 0);
|
||||
assert.equal(output.summary.always.tokens, alwaysSum,
|
||||
'summary.always.tokens must equal sum of always-loaded sources');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildManifest — load-pattern accounting (unit)', () => {
|
||||
// Hand-built activeConfig covering every source kind, with a disabled MCP
|
||||
// server and a plugin roll-up entry that must both be excluded from the
|
||||
// ranked sources + totals.
|
||||
const activeConfig = {
|
||||
claudeMd: {
|
||||
files: [
|
||||
{ path: '/root/CLAUDE.md', scope: 'project', bytes: 100 },
|
||||
{ path: '/home/.claude/CLAUDE.md', scope: 'user', bytes: 100 },
|
||||
],
|
||||
totalBytes: 200,
|
||||
estimatedTokens: 50, // → 25 tokens each
|
||||
},
|
||||
// Must be ignored entirely (roll-up double-counts components).
|
||||
plugins: [{ name: 'plug', estimatedTokens: 9999 }],
|
||||
skills: [
|
||||
{ name: 'sk1', source: 'user', pluginName: null, estimatedTokens: 40 },
|
||||
{ name: 'sk2', source: 'plugin', pluginName: 'plug', estimatedTokens: 30 },
|
||||
],
|
||||
rules: [
|
||||
{ name: 'unscoped.md', source: 'project', pluginName: null, scoped: false,
|
||||
estimatedTokens: 10, ...deriveLoadPattern('rule', { scoped: false }) },
|
||||
{ name: 'scoped.md', source: 'project', pluginName: null, scoped: true,
|
||||
estimatedTokens: 8, ...deriveLoadPattern('rule', { scoped: true }) },
|
||||
],
|
||||
agents: [
|
||||
{ name: 'a1', source: 'project', pluginName: null, estimatedTokens: 5,
|
||||
...deriveLoadPattern('agent') },
|
||||
],
|
||||
outputStyles: [
|
||||
{ name: 's1', source: 'project', pluginName: null, estimatedTokens: 7,
|
||||
...deriveLoadPattern('output-style') },
|
||||
],
|
||||
mcpServers: [
|
||||
{ name: 'm1', source: 'project', estimatedTokens: 500, enabled: true },
|
||||
{ name: 'm2', source: 'project', estimatedTokens: 999, enabled: false },
|
||||
],
|
||||
hooks: [
|
||||
{ event: 'PreToolUse', matcher: 'Edit', source: 'project', estimatedTokens: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const built = buildManifest(activeConfig);
|
||||
const byName = (kind, name) => built.sources.find(s => s.kind === kind && s.name === name);
|
||||
|
||||
it('emits no kind:plugin source (roll-up dropped, component-level only)', () => {
|
||||
assert.ok(built.sources.every(s => s.kind !== 'plugin'));
|
||||
});
|
||||
|
||||
it('excludes the plugin roll-up and disabled MCP from the total', () => {
|
||||
// 25+25 (claude-md) + 40+30 (skills) + 10+8 (rules) + 5 (agent) + 7 (style) + 500 (mcp m1)
|
||||
assert.equal(built.total, 650);
|
||||
});
|
||||
|
||||
it('tags skills as on-demand (body paid on invoke, not every turn)', () => {
|
||||
assert.equal(byName('skill', 'sk1').loadPattern, 'on-demand');
|
||||
assert.equal(byName('skill', 'sk1').survivesCompaction, 'n/a');
|
||||
});
|
||||
|
||||
it('propagates rule load-pattern (unscoped=always, scoped=on-demand)', () => {
|
||||
assert.equal(byName('rule', 'unscoped.md').loadPattern, 'always');
|
||||
assert.equal(byName('rule', 'scoped.md').loadPattern, 'on-demand');
|
||||
});
|
||||
|
||||
it('tags agents and output styles as always-loaded', () => {
|
||||
assert.equal(byName('agent', 'a1').loadPattern, 'always');
|
||||
assert.equal(byName('output-style', 's1').loadPattern, 'always');
|
||||
});
|
||||
|
||||
it('tags MCP servers always and hooks external', () => {
|
||||
assert.equal(byName('mcp-server', 'm1').loadPattern, 'always');
|
||||
assert.equal(byName('hook', 'PreToolUse:Edit').loadPattern, 'external');
|
||||
});
|
||||
|
||||
it('excludes the disabled MCP server from sources', () => {
|
||||
assert.equal(byName('mcp-server', 'm2'), undefined);
|
||||
});
|
||||
|
||||
it('maps CLAUDE.md scope to the correct load pattern', () => {
|
||||
assert.equal(byName('claude-md', '/root/CLAUDE.md').loadPattern, 'always');
|
||||
assert.equal(byName('claude-md', '/root/CLAUDE.md').survivesCompaction, 'yes');
|
||||
assert.equal(byName('claude-md', '/home/.claude/CLAUDE.md').loadPattern, 'always');
|
||||
});
|
||||
|
||||
it('summary buckets reconcile with per-source tags', () => {
|
||||
// always: 25+25+10+5+7+500 = 572 over 6 sources
|
||||
assert.equal(built.summary.always.tokens, 572);
|
||||
assert.equal(built.summary.always.count, 6);
|
||||
// on-demand: 40+30+8 = 78 over 3 sources
|
||||
assert.equal(built.summary.onDemand.tokens, 78);
|
||||
assert.equal(built.summary.onDemand.count, 3);
|
||||
// external: hook (0 tokens) over 1 source
|
||||
assert.equal(built.summary.external.tokens, 0);
|
||||
assert.equal(built.summary.external.count, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => {
|
||||
|
|
@ -85,14 +212,20 @@ describe('manifest CLI — fixture path (rich-repo with patched HOME)', () => {
|
|||
`expected sources.length >= 5, got ${out.sources.length}: ${out.sources.map(s => `${s.kind}:${s.name}`).join(', ')}`);
|
||||
});
|
||||
|
||||
it('includes both plugins (manifest-plugin-a + manifest-plugin-b)', () => {
|
||||
it('drops the coarse plugin roll-up (component-level only, no kind:plugin)', () => {
|
||||
const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome });
|
||||
const out = JSON.parse(proc.stdout);
|
||||
const pluginNames = out.sources.filter(s => s.kind === 'plugin').map(s => s.name);
|
||||
assert.ok(pluginNames.includes('manifest-plugin-a'),
|
||||
`expected manifest-plugin-a in plugins; got: ${pluginNames.join(', ')}`);
|
||||
assert.ok(pluginNames.includes('manifest-plugin-b'),
|
||||
`expected manifest-plugin-b in plugins; got: ${pluginNames.join(', ')}`);
|
||||
assert.ok(out.sources.every(s => s.kind !== 'plugin'),
|
||||
`expected no kind:plugin source (roll-up dropped); got: ${out.sources.map(s => s.kind).join(', ')}`);
|
||||
});
|
||||
|
||||
it('surfaces plugin-provided skills as component-level sources', () => {
|
||||
const proc = runCli([fixture.root, '--json'], { HOME: fixture.fakeHome });
|
||||
const out = JSON.parse(proc.stdout);
|
||||
const fromA = out.sources.find(s => s.kind === 'skill' && s.name === 'alpha-skill');
|
||||
assert.ok(fromA, 'expected alpha-skill as a component-level skill source');
|
||||
assert.ok(/^plugin:/.test(fromA.source),
|
||||
`expected plugin-provided skill source to be namespaced plugin:*; got: ${fromA.source}`);
|
||||
});
|
||||
|
||||
it('includes 3 fixture skills (alpha-skill, beta-skill, gamma-skill)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue