Files
gstack/test/resolver-entry.test.ts
T
Garry Tan b5f75c18c7 test(coverage): close 5 remaining v1.46.0.0 test gaps (A-E)
Five behaviors that v1.46 ships but had no test coverage. All now pinned.

A) --host all idempotency (test/gen-skill-docs-idempotency.test.ts)
   The default test ran Claude host only. Non-Claude hosts (Codex, Factory,
   Cursor, OpenClaw, GBrain, Slate, OpenCode, Hermes, Kiro) each have their
   own output paths and could carry their own non-deterministic fields. We
   hit a "--host all needed for freshness check" mid-/ship. Now: two
   consecutive `bun run gen:skill-docs --host all` runs must produce
   byte-identical outputs across a per-host sample (.agents/, .cursor/,
   .factory/, .gbrain/). Catches per-host adapter regressions before CI.

B) --catalog-mode=full opt-out (test/catalog-mode-full.test.ts)
   The legacy escape hatch had zero tests. 6 new tests across two layers:
   static (CATALOG_MODE_ARG parsed; conditional gate present; default is
   "trim"; invalid value throws) + smoke (actual --catalog-mode=full run
   produces a multi-line `description: |` block + omits "## When to invoke"
   body section; mutates the working tree then restores in a finally block).

C) parity-baseline-v1.44.1.json integrity (test/parity-baseline-integrity.test.ts)
   The baseline is the source of every v1→v2 number cited in the
   CHANGELOG v1.46.0.0 entry. Anyone could edit it without test failure
   until now. 8 new tests pin: existence, tag, capturedFromCommit
   allowlist, expected v1.44 numbers (51 skills, ~2,915 KB, ~9,319
   catalog tokens), CHANGELOG references this file by path, per-skill
   shape, and a SHA256 byte-stability hash. Any edit fails with a clear
   "if intentional, update EXPECTED_HASH AND the CHANGELOG numbers" signal.

D) Live appliesTo gate end-to-end (test/resolver-entry.test.ts extended)
   The unwrapResolver unit tests covered the function; the gen-skill-docs.ts
   substitution loop that USES the gate had no integration coverage. 6 new
   tests simulate the exact 4-line shape from gen-skill-docs.ts:457-467
   against synthetic registries: plain-function fires unconditionally,
   gated fires when true / empty-string when false, mixed registries
   compose, parameterized resolvers respect gates, unknown resolvers throw.

E) Per-skill min-size floor (test/skill-size-budget.test.ts extended)
   The existing 200-byte body coverage-floor is a noise floor — a skill
   that lost 99.75% of content still passes. 1 new test asserts every
   skill stays ≥80% of its v1.44.1 baseline size (the parity-suite
   content invariants only covered 10 of 51 skills; the remaining 41
   were uncovered). SECTIONS_EXTRACTED hook in place for v2.0.0.0 when
   the sections/ pattern legitimately shrinks ship/plan-ceo/etc. past
   the floor.

Test plan:
- bun test focused 17-file suite: 1202 pass, 0 fail
  (+23 new tests vs the pre-fill 1179 baseline)
- catalog-mode=full mutates working tree then restores cleanly
- --host all idempotency runs two full gen passes in <1s on this machine

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:49:59 -07:00

187 lines
7.1 KiB
TypeScript

/**
* Unit tests for the ResolverEntry / unwrapResolver mechanism.
*
* Verifies the conditional-injection plumbing added in T2 (v1.45.0.0).
* Plain functions still work; gated entries skip when appliesTo returns false.
*/
import { describe, test, expect } from 'bun:test';
import { unwrapResolver, type ResolverFn, type ResolverEntry, type TemplateContext } from '../scripts/resolvers/types';
function makeCtx(overrides: Partial<TemplateContext> = {}): TemplateContext {
return {
skillName: 'test-skill',
tmplPath: '/tmp/test/SKILL.md.tmpl',
host: 'claude',
paths: {
skillRoot: '~/.claude/skills/gstack',
localSkillRoot: '.claude/skills',
binDir: '~/.claude/skills/gstack/bin',
browseDir: '~/.claude/skills/gstack/browse/dist',
designDir: '~/.claude/skills/gstack/design/dist',
makePdfDir: '~/.claude/skills/gstack/make-pdf/dist',
},
...overrides,
};
}
describe('unwrapResolver — plain function pass-through', () => {
test('returns the function as-is, no gate', () => {
const fn: ResolverFn = (ctx) => `hello-${ctx.skillName}`;
const { resolve, appliesTo } = unwrapResolver(fn);
expect(resolve(makeCtx())).toBe('hello-test-skill');
expect(appliesTo).toBeUndefined();
});
});
describe('unwrapResolver — gated entry', () => {
test('returns resolve + gate', () => {
const entry: ResolverEntry = {
resolve: (ctx) => `gated-${ctx.skillName}`,
appliesTo: (ctx) => ['ship', 'review'].includes(ctx.skillName),
};
const { resolve, appliesTo } = unwrapResolver(entry);
expect(resolve(makeCtx({ skillName: 'ship' }))).toBe('gated-ship');
expect(appliesTo!(makeCtx({ skillName: 'ship' }))).toBe(true);
expect(appliesTo!(makeCtx({ skillName: 'qa' }))).toBe(false);
});
test('gate returning false should signal skip — gen-skill-docs substitutes empty string', () => {
// This mirrors the gen-skill-docs.ts contract:
// if (appliesTo && !appliesTo(ctx)) return '';
const entry: ResolverEntry = {
resolve: () => 'CONTENT',
appliesTo: () => false,
};
const { resolve, appliesTo } = unwrapResolver(entry);
const result = appliesTo && !appliesTo(makeCtx()) ? '' : resolve(makeCtx());
expect(result).toBe('');
});
test('gate returning true allows resolve to fire', () => {
const entry: ResolverEntry = {
resolve: () => 'CONTENT',
appliesTo: () => true,
};
const { resolve, appliesTo } = unwrapResolver(entry);
const result = appliesTo && !appliesTo(makeCtx()) ? '' : resolve(makeCtx());
expect(result).toBe('CONTENT');
});
test('entry without appliesTo behaves like ungated', () => {
const entry: ResolverEntry = { resolve: () => 'ALWAYS' };
const { resolve, appliesTo } = unwrapResolver(entry);
expect(appliesTo).toBeUndefined();
expect(resolve(makeCtx())).toBe('ALWAYS');
});
});
describe('RESOLVERS registry still loads with mixed shapes', () => {
test('importing the live registry produces a record with expected resolvers', async () => {
const { RESOLVERS } = await import('../scripts/resolvers/index');
// Spot-check that core resolvers are present.
expect(RESOLVERS.PREAMBLE).toBeDefined();
expect(RESOLVERS.REVIEW_DASHBOARD).toBeDefined();
expect(RESOLVERS.SLUG_EVAL).toBeDefined();
// Each entry should unwrap cleanly.
for (const [name, entry] of Object.entries(RESOLVERS)) {
const { resolve } = unwrapResolver(entry);
expect(typeof resolve).toBe('function');
expect(name.length).toBeGreaterThan(0);
}
});
});
/**
* Gap D (v1.46.0.0): live appliesTo gate end-to-end integration.
*
* The ResolverEntry / unwrapResolver machinery has unit coverage above. The
* remaining gap: does the gen-skill-docs.ts:444 substitution loop actually
* USE the gate? A refactor that drops the `if (appliesTo && !appliesTo(ctx))`
* check would silently break every future gated resolver.
*
* This test simulates the exact 4-line shape the live pipeline uses against
* a synthetic registry. If gen-skill-docs.ts is refactored and someone
* forgets to keep the gate check in sync, this assertion fails.
*/
describe('gen-skill-docs substitution loop respects the appliesTo gate', () => {
function simulateGenSubstitution(
template: string,
registry: Record<string, import('../scripts/resolvers/types').ResolverValue>,
ctx: TemplateContext,
): string {
// Mirrors scripts/gen-skill-docs.ts:457-467 (the {{NAME}} substitution
// loop). Keep this in sync with the real loop. Drift here is what the
// test is designed to catch.
return template.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
const parts = fullKey.split(':');
const resolverName = parts[0];
const args = parts.slice(1);
const entry = registry[resolverName];
if (!entry) throw new Error(`Unknown placeholder {{${resolverName}}}`);
const { resolve, appliesTo } = unwrapResolver(entry);
if (appliesTo && !appliesTo(ctx)) return '';
return args.length > 0 ? resolve(ctx, args) : resolve(ctx);
});
}
test('plain-function resolver fires unconditionally', () => {
const tpl = '{{ALWAYS}}';
const out = simulateGenSubstitution(tpl, {
ALWAYS: () => 'fired',
}, makeCtx({ skillName: 'whatever' }));
expect(out).toBe('fired');
});
test('gated resolver fires only when appliesTo returns true', () => {
const tpl = 'before-{{GATED}}-after';
const out = simulateGenSubstitution(tpl, {
GATED: {
resolve: () => 'CONTENT',
appliesTo: (ctx) => ctx.skillName === 'allowed',
},
}, makeCtx({ skillName: 'allowed' }));
expect(out).toBe('before-CONTENT-after');
});
test('gated resolver is substituted with empty string when appliesTo returns false', () => {
const tpl = 'before-{{GATED}}-after';
const out = simulateGenSubstitution(tpl, {
GATED: {
resolve: () => 'CONTENT',
appliesTo: (ctx) => ctx.skillName === 'allowed',
},
}, makeCtx({ skillName: 'something-else' }));
expect(out).toBe('before--after');
});
test('mixed registry: gated + plain resolvers in the same template', () => {
const tpl = '{{PLAIN}} / {{GATED_ON}} / {{GATED_OFF}}';
const ctx = makeCtx({ skillName: 'ship' });
const out = simulateGenSubstitution(tpl, {
PLAIN: () => 'plain',
GATED_ON: { resolve: () => 'on', appliesTo: () => true },
GATED_OFF: { resolve: () => 'off', appliesTo: () => false },
}, ctx);
expect(out).toBe('plain / on / ');
});
test('parameterized resolver still respects gate', () => {
const tpl = '{{GATED:arg1:arg2}}';
const ctx = makeCtx({ skillName: 'no' });
const out = simulateGenSubstitution(tpl, {
GATED: {
resolve: (_c, args) => `fired-with-${(args ?? []).join('-')}`,
appliesTo: (c) => c.skillName === 'yes',
},
}, ctx);
expect(out).toBe(''); // gated off, args ignored
});
test('unknown resolver throws (matches real gen-skill-docs error contract)', () => {
expect(() =>
simulateGenSubstitution('{{NEVER_DEFINED}}', {}, makeCtx()),
).toThrow(/Unknown placeholder/);
});
});