mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-26 19:49:57 +02:00
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>
This commit is contained in:
@@ -91,3 +91,96 @@ describe('RESOLVERS registry still loads with mixed shapes', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user