* feat(#2792): namespace meta-skills retargeted at the post-#2790 surface This branch is now based on #2790's HEAD (the consolidation PR) instead of main, and every routing table targets the consolidated surface so a user routed by a namespace meta-skill never lands at a deleted / folded sub-skill. Cross-PR inconsistencies the original PR #2825 carried (vs #2790): - ns-ideate routed to gsd-note / gsd-add-todo / gsd-add-backlog / gsd-plant-seed → all folded into gsd-capture by #2790. Now routes to gsd-capture (the parent picks the mode from the user's intent). - ns-context routed to gsd-scan and gsd-intel → folded into gsd-map-codebase --fast / --query by #2790. Now routes to those flag forms. - ns-manage routed all workspace intent to gsd-list-workspaces (a list-only entry) → CR also flagged the over-narrow target. #2790 folds into gsd-workspace; routing now points there. - ns-workflow routed to gsd-research-phase → deleted outright by #2790. Removed. - ns-project routed to gsd-plan-milestone-gaps → deleted outright by #2790. Removed. - None of the namespaces previously surfaced #2790's new consolidated skills (gsd-capture, gsd-phase, gsd-config, gsd-workspace, gsd-progress). All five are now reachable through the routers. - extract_learnings → extract-learnings (canonicalized by #2858). Defect fixes within the namespace skills: - Hyphen-form `name:` (gsd-workflow, …) per the canonical naming contract — the colon-form addressed CR's drift complaint. - `Skill` added to allowed-tools on every router. The body instructs "Invoke the matched skill directly using the Skill tool" — without Skill in the permission list the meta-skill cannot route at all. New regression guard in tests/enh-2792-namespace-skills.test.cjs: every gsd-* token in any namespace router's table column resolves to a surviving commands/gsd/*.md file (or to a known consolidated parent for flag-form targets like gsd-map-codebase --fast). This single test would have caught every dead-end route the original PR shipped with. Skill-count cap in tests/enh-2790-skill-consolidation.test.cjs now filters out ns-*.md from its <= 63 cap. Namespace routers are descriptor-only entries, not part of the consolidation surface that cap is policing — they have their own contract in tests/enh-2792-namespace-skills.test.cjs. INVENTORY.md gains a "Namespace Meta-Skills" section with the 6 router rows; INVENTORY-MANIFEST.json gains 6 entries; the headline count moves 59 → 65 to match. Out of scope for this rebase: the gsd-health --context flag (PR #2825 advertised the contract but didn't implement it). That's a separate feature concern and is left untouched here. 5908/5908 on `npm test`. * feat(#2792): implement gsd-health --context utilization guard The original PR #2825 advertised a `--context` flag on gsd-health with a 60%/70% utilization threshold table but never implemented the workflow logic — CR caught it as a contract leak, the rebase deferred it. This commit closes the gap with TDD red/green/refactor. Math layer (pure): - get-shit-done/bin/lib/context-utilization.cjs classifyContextUtilization(tokensUsed, contextWindow) → { percent, state } State boundaries use the exact ratio: < 60% healthy / 60–70% warning / ≥ 70% critical (fracture point) Display percent rounded for humans. Throws TypeError on non-integer or out-of-range inputs. - STATES = Object.freeze({ HEALTHY, WARNING, CRITICAL }) exported so callers reference the names by symbol, not by literal string. SDK CLI integration: - get-shit-done/bin/gsd-tools.cjs `validate context --tokens-used N --context-window M [--json]` routes to the classifier, owns the recommendation copy (the classifier intentionally does not — keeps the renderer free to evolve without touching the math layer or its tests), and uses core.output's rawValue path for the sync-flush guarantee. - sdk/src/query/validate.ts + sdk/src/query/index.ts TypeScript validateContext handler registered at 'validate.context' and 'validate context'. Mirrors the CJS classifier inline (15 lines of arithmetic; not worth a shared cross-language module). User-facing wiring: - commands/gsd/health.md frontmatter advertises --context, body documents the three-state threshold table. - get-shit-done/workflows/health.md adds a `context_check` step that's reached only when --context is set. Step calls `gsd-sdk query validate.context` with self-reported tokensUsed and contextWindow, prints the SDK output verbatim, and ends. Includes a TEXT_MODE plain-text fallback for non-Claude runtimes per #2012. Tests: - tests/context-utilization.test.cjs (17 tests) — pure-function contract: state thresholds at every boundary, percent rounding, input validation, return-shape (no recommendation field — that's the renderer's job). - tests/validate-context.test.cjs (9 tests) — SDK CLI plumbing: arg parsing errors, JSON vs human rendering, recommendation copy pinned per state. - tests/enh-2792-namespace-skills.test.cjs (4 new tests) — markdown contract: --context advertised in argument-hint, threshold table in command body, context_check step exists in workflow, step invokes gsd-sdk query validate.context with both flags. Inventory bookkeeping: - docs/INVENTORY.md "CLI Modules" 31 → 32; new row for context-utilization.cjs. - docs/INVENTORY-MANIFEST.json mirror. 5939/5939 on `npm test`.
This commit is contained in:
122
tests/context-utilization.test.cjs
Normal file
122
tests/context-utilization.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pure classifier for the gsd-health --context guard.
|
||||
*
|
||||
* Thresholds:
|
||||
* < 60% healthy
|
||||
* 60–70% warning
|
||||
* ≥ 70% critical (fracture point)
|
||||
*
|
||||
* The classifier is a pure (tokensUsed, contextWindow) → { percent, state }
|
||||
* function. Recommendation copy is owned by the SDK renderer (see
|
||||
* tests/validate-context.test.cjs), not this module.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { classifyContextUtilization, STATES } = require('../get-shit-done/bin/lib/context-utilization.cjs');
|
||||
|
||||
describe('STATES constant exposes the three boundary names', () => {
|
||||
test('exports HEALTHY, WARNING, CRITICAL', () => {
|
||||
assert.deepStrictEqual(
|
||||
[STATES.HEALTHY, STATES.WARNING, STATES.CRITICAL],
|
||||
['healthy', 'warning', 'critical'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyContextUtilization — state thresholds', () => {
|
||||
test('0 tokens used → healthy at 0%', () => {
|
||||
const r = classifyContextUtilization(0, 200_000);
|
||||
assert.strictEqual(r.percent, 0);
|
||||
assert.strictEqual(r.state, STATES.HEALTHY);
|
||||
});
|
||||
|
||||
test('just under 60% → healthy (state uses exact ratio, not rounded percent)', () => {
|
||||
// 119_999 / 200_000 = 59.9995% — rounds to 60 for display, healthy by ratio.
|
||||
const r = classifyContextUtilization(119_999, 200_000);
|
||||
assert.strictEqual(r.state, STATES.HEALTHY);
|
||||
});
|
||||
|
||||
test('exactly 60% → warning (inclusive lower bound)', () => {
|
||||
const r = classifyContextUtilization(120_000, 200_000);
|
||||
assert.strictEqual(r.percent, 60);
|
||||
assert.strictEqual(r.state, STATES.WARNING);
|
||||
});
|
||||
|
||||
test('between 60% and 70% → warning', () => {
|
||||
const r = classifyContextUtilization(130_000, 200_000);
|
||||
assert.strictEqual(r.percent, 65);
|
||||
assert.strictEqual(r.state, STATES.WARNING);
|
||||
});
|
||||
|
||||
test('just under 70% → warning', () => {
|
||||
// 139_999 / 200_000 = 69.9995% — rounds to 70 for display, warning by ratio.
|
||||
const r = classifyContextUtilization(139_999, 200_000);
|
||||
assert.strictEqual(r.state, STATES.WARNING);
|
||||
});
|
||||
|
||||
test('exactly 70% → critical (fracture point, inclusive lower bound)', () => {
|
||||
const r = classifyContextUtilization(140_000, 200_000);
|
||||
assert.strictEqual(r.percent, 70);
|
||||
assert.strictEqual(r.state, STATES.CRITICAL);
|
||||
});
|
||||
|
||||
test('above 70% → critical', () => {
|
||||
const r = classifyContextUtilization(180_000, 200_000);
|
||||
assert.strictEqual(r.percent, 90);
|
||||
assert.strictEqual(r.state, STATES.CRITICAL);
|
||||
});
|
||||
|
||||
test('tokensUsed >= contextWindow clamps to 100%', () => {
|
||||
const r = classifyContextUtilization(250_000, 200_000);
|
||||
assert.strictEqual(r.percent, 100);
|
||||
assert.strictEqual(r.state, STATES.CRITICAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyContextUtilization — return shape', () => {
|
||||
test('result is exactly { percent, state } — no recommendation field', () => {
|
||||
// Recommendation copy lives in the renderer, not the classifier.
|
||||
// Keeping this contract narrow lets the prose evolve without
|
||||
// re-validating the math layer.
|
||||
const r = classifyContextUtilization(100_000, 200_000);
|
||||
assert.deepStrictEqual(Object.keys(r).sort(), ['percent', 'state']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyContextUtilization — input validation', () => {
|
||||
test('negative tokensUsed throws', () => {
|
||||
assert.throws(() => classifyContextUtilization(-1, 200_000), /tokensUsed/);
|
||||
});
|
||||
|
||||
test('non-integer tokensUsed throws', () => {
|
||||
assert.throws(() => classifyContextUtilization(1.5, 200_000), /tokensUsed/);
|
||||
});
|
||||
|
||||
test('zero contextWindow throws', () => {
|
||||
assert.throws(() => classifyContextUtilization(100, 0), /contextWindow/);
|
||||
});
|
||||
|
||||
test('negative contextWindow throws', () => {
|
||||
assert.throws(() => classifyContextUtilization(100, -1), /contextWindow/);
|
||||
});
|
||||
|
||||
test('non-number inputs throw via Number.isInteger', () => {
|
||||
assert.throws(() => classifyContextUtilization('100', 200_000), /tokensUsed/);
|
||||
assert.throws(() => classifyContextUtilization(100, '200000'), /contextWindow/);
|
||||
assert.throws(() => classifyContextUtilization(NaN, 200_000), /tokensUsed/);
|
||||
assert.throws(() => classifyContextUtilization(100, Infinity), /contextWindow/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyContextUtilization — percent rounding', () => {
|
||||
test('display percent rounds; state uses exact ratio', () => {
|
||||
// 119_998 / 200_000 = 59.999% — display rounds to 60, state stays healthy.
|
||||
const r = classifyContextUtilization(119_998, 200_000);
|
||||
assert.strictEqual(r.state, STATES.HEALTHY);
|
||||
assert.ok([59, 60].includes(r.percent), `expected percent ∈ {59,60}, got ${r.percent}`);
|
||||
});
|
||||
});
|
||||
@@ -250,12 +250,17 @@ describe('settings.md is kept (merged into config entry point or remains standal
|
||||
// Group: Skill count reduced
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('skill count', () => {
|
||||
test('total files in commands/gsd/*.md is <= 63', () => {
|
||||
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
test('total user-invocable files in commands/gsd/*.md is <= 63', () => {
|
||||
// Exclude `ns-*.md` namespace meta-skills (#2792) from this cap.
|
||||
// Those are descriptor-only routers selected first by the model and
|
||||
// are not part of the consolidation surface this test tracks; their
|
||||
// own contract is enforced by tests/enh-2792-namespace-skills.test.cjs.
|
||||
const files = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md') && !f.startsWith('ns-'));
|
||||
assert.ok(
|
||||
files.length <= 63,
|
||||
[
|
||||
`Expected <= 63 skill files, found ${files.length}.`,
|
||||
`Expected <= 63 user-invocable skill files, found ${files.length}.`,
|
||||
'Consolidation target is ~58.',
|
||||
].join(' '),
|
||||
);
|
||||
|
||||
255
tests/enh-2792-namespace-skills.test.cjs
Normal file
255
tests/enh-2792-namespace-skills.test.cjs
Normal file
@@ -0,0 +1,255 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// commands/gsd/*.md files ARE what the runtime loads — testing their
|
||||
// frontmatter content tests the deployed system-prompt contract.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
const NAMESPACE_SKILLS = [
|
||||
{ file: 'ns-workflow.md', name: 'gsd-workflow' },
|
||||
{ file: 'ns-project.md', name: 'gsd-project' },
|
||||
{ file: 'ns-review.md', name: 'gsd-review' },
|
||||
{ file: 'ns-context.md', name: 'gsd-context' },
|
||||
{ file: 'ns-manage.md', name: 'gsd-manage' },
|
||||
{ file: 'ns-ideate.md', name: 'gsd-ideate' },
|
||||
];
|
||||
|
||||
// Route targets named in any namespace body. The cross-reference test below
|
||||
// asserts that every one of these resolves to a surviving command file or to
|
||||
// a known consolidated parent (which absorbs flag-form invocations of folded
|
||||
// skills, e.g. `gsd-map-codebase --fast` for the former `gsd-scan`).
|
||||
const FLAG_FORM_PARENTS = new Set([
|
||||
'gsd-code-review', // --fix absorbs former gsd-code-review-fix
|
||||
'gsd-map-codebase', // --fast absorbs scan, --query absorbs intel
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse the leading YAML frontmatter block of a markdown file into a
|
||||
* shallow `{ key: value }` map plus the trailing body. Splits on `\r?\n`
|
||||
* for CRLF tolerance and uses trimmed-line equality for the `---`
|
||||
* delimiters so whitespace-padded delimiter lines are accepted.
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
let openIdx = -1;
|
||||
let closeIdx = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (lines[i].trim() === '---') {
|
||||
if (openIdx === -1) openIdx = i;
|
||||
else { closeIdx = i; break; }
|
||||
}
|
||||
}
|
||||
assert.ok(openIdx !== -1 && closeIdx !== -1, 'frontmatter block must be delimited by --- on its own lines');
|
||||
const fm = {};
|
||||
for (const line of lines.slice(openIdx + 1, closeIdx)) {
|
||||
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const [, key, raw] = m;
|
||||
const value = raw.trim().replace(/^["']|["']$/g, '');
|
||||
fm[key] = value;
|
||||
}
|
||||
fm._body = lines.slice(closeIdx + 1).join('\n');
|
||||
return fm;
|
||||
}
|
||||
|
||||
function readNamespaceFile(file) {
|
||||
const filePath = path.join(COMMANDS_DIR, file);
|
||||
assert.ok(fs.existsSync(filePath), `${file} must exist at ${filePath}`);
|
||||
return { filePath, ...parseFrontmatter(fs.readFileSync(filePath, 'utf-8')) };
|
||||
}
|
||||
|
||||
// ── Frontmatter contract ───────────────────────────────────────────────
|
||||
|
||||
describe('Namespace skill files exist with correct name', () => {
|
||||
for (const { file, name } of NAMESPACE_SKILLS) {
|
||||
test(`${file} — name field is hyphen-form ${name}`, () => {
|
||||
const fm = readNamespaceFile(file);
|
||||
assert.strictEqual(
|
||||
fm.name,
|
||||
name,
|
||||
`name: in ${file} must be ${name} (hyphen form per #2858), got: ${fm.name}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Namespace skill descriptions are keyword-tag format', () => {
|
||||
for (const { file } of NAMESPACE_SKILLS) {
|
||||
test(`${file} — description ≤ 60 chars`, () => {
|
||||
const fm = readNamespaceFile(file);
|
||||
assert.ok(
|
||||
fm.description.length <= 60,
|
||||
`${file} description must be ≤ 60 chars, got ${fm.description.length}: ${fm.description}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${file} — description contains a pipe separator`, () => {
|
||||
const fm = readNamespaceFile(file);
|
||||
assert.ok(
|
||||
fm.description.includes('|'),
|
||||
`${file} description must contain | pipe separator, got: ${fm.description}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${file} — description does not start with prose ("Use " / "This skill")`, () => {
|
||||
const { description } = readNamespaceFile(file);
|
||||
assert.ok(
|
||||
!description.startsWith('Use ') && !description.startsWith('This skill'),
|
||||
`${file} description must not start with "Use " or "This skill", got: ${description}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── allowed-tools must include Skill ──────────────────────────────────
|
||||
|
||||
describe('Namespace skills permit Skill execution', () => {
|
||||
for (const { file } of NAMESPACE_SKILLS) {
|
||||
test(`${file} — allowed-tools includes Skill`, () => {
|
||||
const filePath = path.join(COMMANDS_DIR, file);
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const startIdx = lines.findIndex((l) => l.trim() === 'allowed-tools:');
|
||||
assert.ok(startIdx !== -1, `${file} must declare an allowed-tools block`);
|
||||
const tools = [];
|
||||
for (let i = startIdx + 1; i < lines.length; i += 1) {
|
||||
const m = lines[i].match(/^\s+-\s+(\S+)/);
|
||||
if (!m) break;
|
||||
tools.push(m[1]);
|
||||
}
|
||||
assert.ok(
|
||||
tools.includes('Skill'),
|
||||
`${file} body invokes the Skill tool but allowed-tools does not include Skill (got: ${tools.join(', ')})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Body contains routing table ───────────────────────────────────────
|
||||
|
||||
describe('Namespace skill bodies carry a routing table', () => {
|
||||
for (const { file } of NAMESPACE_SKILLS) {
|
||||
test(`${file} — body contains "| User wants" table header`, () => {
|
||||
const fm = readNamespaceFile(file);
|
||||
const lines = fm._body.split('\n');
|
||||
const hasHeader = lines.some((l) => l.includes('| User wants'));
|
||||
assert.ok(hasHeader, `${file} body must contain a routing table starting with "| User wants"`);
|
||||
});
|
||||
|
||||
test(`${file} — body has at least one Invoke target`, () => {
|
||||
const fm = readNamespaceFile(file);
|
||||
const hasInvoke = /\bgsd-[a-z-]+/i.test(fm._body);
|
||||
assert.ok(hasInvoke, `${file} body must reference at least one gsd-* sub-skill`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Context guard contract on gsd-health ──────────────────────────────
|
||||
// Asserts the `--context` surface promised by #2792 is wired through to
|
||||
// both the command frontmatter and the workflow body. The classifier
|
||||
// itself is covered by tests/context-utilization.test.cjs and the SDK
|
||||
// CLI by tests/validate-context.test.cjs.
|
||||
|
||||
describe('gsd-health --context flag is wired into command + workflow', () => {
|
||||
const HEALTH_CMD = path.join(COMMANDS_DIR, 'health.md');
|
||||
const HEALTH_WORKFLOW = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'health.md');
|
||||
|
||||
test('commands/gsd/health.md argument-hint advertises --context', () => {
|
||||
const raw = fs.readFileSync(HEALTH_CMD, 'utf-8');
|
||||
const fm = parseFrontmatter(raw);
|
||||
assert.ok(
|
||||
fm['argument-hint'] && fm['argument-hint'].includes('--context'),
|
||||
`health.md argument-hint must include --context, got: ${fm['argument-hint']}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('commands/gsd/health.md body documents the three-state utilization table', () => {
|
||||
const raw = fs.readFileSync(HEALTH_CMD, 'utf-8');
|
||||
const body = parseFrontmatter(raw)._body.toLowerCase();
|
||||
assert.ok(body.includes('healthy'), 'body must name the healthy state');
|
||||
assert.ok(body.includes('warning'), 'body must name the warning state');
|
||||
assert.ok(body.includes('critical'), 'body must name the critical state');
|
||||
assert.ok(
|
||||
body.includes('60%') && body.includes('70%'),
|
||||
'body must reference the 60% and 70% threshold boundaries',
|
||||
);
|
||||
});
|
||||
|
||||
test('get-shit-done/workflows/health.md has a context_check step', () => {
|
||||
const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8');
|
||||
assert.match(
|
||||
raw,
|
||||
/<step name="context_check">/,
|
||||
'workflow must define a <step name="context_check"> branch',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow context_check invokes gsd-sdk query validate.context', () => {
|
||||
const raw = fs.readFileSync(HEALTH_WORKFLOW, 'utf-8');
|
||||
// Extract just the context_check step's body so a stray reference
|
||||
// elsewhere in the file can't satisfy this assertion.
|
||||
const stepMatch = raw.match(/<step name="context_check">([\s\S]*?)<\/step>/);
|
||||
assert.ok(stepMatch, 'context_check step must be a closed <step>...</step> block');
|
||||
const stepBody = stepMatch[1];
|
||||
assert.match(
|
||||
stepBody,
|
||||
/gsd-sdk\s+query\s+validate\.context/,
|
||||
'context_check must call `gsd-sdk query validate.context`',
|
||||
);
|
||||
assert.match(stepBody, /--tokens-used/, 'context_check must pass --tokens-used');
|
||||
assert.match(stepBody, /--context-window/, 'context_check must pass --context-window');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-reference: every routed sub-skill must exist ─────────────────
|
||||
// This is the regression guard the original PR lacked. Without it,
|
||||
// post-#2790 consolidations can quietly invalidate router targets again.
|
||||
|
||||
describe('Namespace router targets resolve to surviving skills', () => {
|
||||
// Build the post-consolidation surviving set once.
|
||||
const surviving = new Set();
|
||||
for (const f of fs.readdirSync(COMMANDS_DIR)) {
|
||||
if (!f.endsWith('.md')) continue;
|
||||
const base = f.replace(/\.md$/, '');
|
||||
if (base.startsWith('ns-')) continue; // namespace routers themselves
|
||||
surviving.add(`gsd-${base}`);
|
||||
// The PR #2858 rename canonicalized extract_learnings → extract-learnings.
|
||||
// Until #2790 rebases onto current main, accept either source filename
|
||||
// as resolving to the canonical hyphenated identifier.
|
||||
if (base === 'extract_learnings') surviving.add('gsd-extract-learnings');
|
||||
}
|
||||
|
||||
for (const { file } of NAMESPACE_SKILLS) {
|
||||
test(`${file} — every routing target resolves`, () => {
|
||||
const fm = readNamespaceFile(file);
|
||||
// Extract every gsd-<name> token that appears in a table-row right column.
|
||||
// Strip flag suffixes (`gsd-foo --bar` → `gsd-foo`) before resolving.
|
||||
const targets = new Set();
|
||||
for (const line of fm._body.split('\n')) {
|
||||
// Only consider markdown table data rows: lines that start with `|`
|
||||
// and have content between pipes. Skip header / separator rows.
|
||||
if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue;
|
||||
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
||||
if (cells.length < 2) continue;
|
||||
for (const m of cells[cells.length - 1].matchAll(/\bgsd-[a-z][a-z0-9-]*/g)) {
|
||||
targets.add(m[0]);
|
||||
}
|
||||
}
|
||||
assert.ok(targets.size > 0, `${file} routing table must reference at least one gsd-* target`);
|
||||
const unresolved = [...targets].filter(
|
||||
(t) => !surviving.has(t) && !FLAG_FORM_PARENTS.has(t),
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
unresolved,
|
||||
[],
|
||||
`${file} routes to skills that don't exist in commands/gsd/: ${unresolved.join(', ')}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
90
tests/validate-context.test.cjs
Normal file
90
tests/validate-context.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SDK CLI integration tests for `gsd-tools validate context`.
|
||||
*
|
||||
* The pure classifier's behavior is covered by
|
||||
* tests/context-utilization.test.cjs — these tests focus on what the CLI
|
||||
* adds on top: argument parsing, JSON vs human-readable rendering,
|
||||
* recommendation-string formatting, and exit-code semantics.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
describe('gsd-tools validate context — CLI argument errors', () => {
|
||||
test('missing --tokens-used fails with named flag in stderr', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--context-window', '200000']);
|
||||
assert.strictEqual(r.success, false);
|
||||
assert.match(r.error, /tokens-used/i);
|
||||
});
|
||||
|
||||
test('missing --context-window fails with named flag in stderr', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '100000']);
|
||||
assert.strictEqual(r.success, false);
|
||||
assert.match(r.error, /context-window/i);
|
||||
});
|
||||
|
||||
test('non-numeric --tokens-used reports the offending flag', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', 'abc', '--context-window', '200000']);
|
||||
assert.strictEqual(r.success, false);
|
||||
assert.match(r.error, /tokens-used/i);
|
||||
});
|
||||
|
||||
test('negative --tokens-used reports the offending flag', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '-1', '--context-window', '200000']);
|
||||
assert.strictEqual(r.success, false);
|
||||
assert.match(r.error, /tokens-used/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-tools validate context — JSON vs human rendering', () => {
|
||||
test('--json emits the classifier result plus a recommendation field', () => {
|
||||
// Single round-trip test confirms (a) classifier integration,
|
||||
// (b) JSON serialization, and (c) recommendation lookup. Per-state
|
||||
// classifier behavior is covered by context-utilization.test.cjs.
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '50000', '--context-window', '200000', '--json']);
|
||||
assert.strictEqual(r.success, true, `expected success, got: ${r.error}`);
|
||||
const obj = JSON.parse(r.output);
|
||||
assert.deepStrictEqual(Object.keys(obj).sort(), ['percent', 'recommendation', 'state']);
|
||||
assert.strictEqual(obj.percent, 25);
|
||||
assert.strictEqual(obj.state, 'healthy');
|
||||
assert.strictEqual(obj.recommendation, null);
|
||||
});
|
||||
|
||||
test('human mode (default) prints percent, state, and recommendation', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '140000', '--context-window', '200000']);
|
||||
assert.strictEqual(r.success, true);
|
||||
assert.match(r.output, /70%/);
|
||||
assert.match(r.output, /critical/);
|
||||
assert.match(r.output, /\/gsd-thread/);
|
||||
});
|
||||
|
||||
test('human mode omits the recommendation line for healthy state', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '40000', '--context-window', '200000']);
|
||||
assert.strictEqual(r.success, true);
|
||||
assert.match(r.output, /20%/);
|
||||
assert.match(r.output, /healthy/);
|
||||
assert.doesNotMatch(r.output, /\/gsd-thread/, 'healthy output must not nag the user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-tools validate context — recommendation copy per state', () => {
|
||||
// The CLI owns the recommendation strings (the classifier does not).
|
||||
// These tests pin the wording so a regression to the prose is caught.
|
||||
test('warning state recommends /gsd-thread', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '130000', '--context-window', '200000', '--json']);
|
||||
const obj = JSON.parse(r.output);
|
||||
assert.strictEqual(obj.state, 'warning');
|
||||
assert.match(obj.recommendation, /\/gsd-thread/);
|
||||
});
|
||||
|
||||
test('critical state names the fracture-point reasoning risk', () => {
|
||||
const r = runGsdTools(['validate', 'context', '--tokens-used', '160000', '--context-window', '200000', '--json']);
|
||||
const obj = JSON.parse(r.output);
|
||||
assert.strictEqual(obj.state, 'critical');
|
||||
assert.match(obj.recommendation, /\/gsd-thread/);
|
||||
assert.match(obj.recommendation, /reasoning|degrade|fracture/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user