mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 21:46:40 +02:00
merge: incorporate origin/main into community-mode branch
Resolve conflict in gen-skill-docs.ts by keeping both the detailed error field instructions (community-mode) and the new Plan Status Footer section (main). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Shared fixture for test coverage audit E2E tests.
|
||||
*
|
||||
* Creates a Node.js project with billing source code that has intentional
|
||||
* test coverage gaps: processPayment has happy-path-only tests,
|
||||
* refundPayment has no tests at all.
|
||||
*
|
||||
* Used by: ship-coverage-audit E2E, review-coverage-audit E2E
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
export function createCoverageAuditFixture(dir: string): void {
|
||||
// Create a Node.js project WITH test framework but coverage gaps
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'test-coverage-app',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: { test: 'echo "no tests yet"' },
|
||||
devDependencies: { vitest: '^1.0.0' },
|
||||
}, null, 2));
|
||||
|
||||
// Create vitest config
|
||||
fs.writeFileSync(path.join(dir, 'vitest.config.ts'),
|
||||
`import { defineConfig } from 'vitest/config';\nexport default defineConfig({ test: {} });\n`);
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'VERSION'), '0.1.0.0\n');
|
||||
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n');
|
||||
|
||||
// Create source file with multiple code paths
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'src', 'billing.ts'), `
|
||||
export function processPayment(amount: number, currency: string) {
|
||||
if (amount <= 0) throw new Error('Invalid amount');
|
||||
if (currency !== 'USD' && currency !== 'EUR') throw new Error('Unsupported currency');
|
||||
return { status: 'success', amount, currency };
|
||||
}
|
||||
|
||||
export function refundPayment(paymentId: string, reason: string) {
|
||||
if (!paymentId) throw new Error('Payment ID required');
|
||||
if (!reason) throw new Error('Reason required');
|
||||
return { status: 'refunded', paymentId, reason };
|
||||
}
|
||||
`);
|
||||
|
||||
// Create a test directory with ONE test (partial coverage)
|
||||
fs.mkdirSync(path.join(dir, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'test', 'billing.test.ts'), `
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { processPayment } from '../src/billing';
|
||||
|
||||
describe('processPayment', () => {
|
||||
test('processes valid payment', () => {
|
||||
const result = processPayment(100, 'USD');
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
// GAP: no test for invalid amount
|
||||
// GAP: no test for unsupported currency
|
||||
// GAP: refundPayment not tested at all
|
||||
});
|
||||
`);
|
||||
|
||||
// Init git repo with main branch
|
||||
const run = (cmd: string, args: string[]) =>
|
||||
spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
||||
run('git', ['init', '-b', 'main']);
|
||||
run('git', ['config', 'user.email', 'test@test.com']);
|
||||
run('git', ['config', 'user.name', 'Test']);
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'initial commit']);
|
||||
|
||||
// Create feature branch
|
||||
run('git', ['checkout', '-b', 'feature/billing']);
|
||||
}
|
||||
+471
-11
@@ -5,6 +5,39 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
const fmEnd = content.indexOf('\n---', 4);
|
||||
expect(fmEnd).toBeGreaterThan(0);
|
||||
const frontmatter = content.slice(4, fmEnd);
|
||||
const lines = frontmatter.split('\n');
|
||||
let description = '';
|
||||
let inDescription = false;
|
||||
const descLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.match(/^description:\s*\|?\s*$/)) {
|
||||
inDescription = true;
|
||||
continue;
|
||||
}
|
||||
if (line.match(/^description:\s*\S/)) {
|
||||
return line.replace(/^description:\s*/, '').trim();
|
||||
}
|
||||
if (inDescription) {
|
||||
if (line === '' || line.match(/^\s/)) {
|
||||
descLines.push(line.replace(/^ /, ''));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (descLines.length > 0) {
|
||||
description = descLines.join('\n').trim();
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
// Dynamic template discovery — matches the generator's findTemplates() behavior.
|
||||
// New skills automatically get test coverage without updating a static list.
|
||||
@@ -98,6 +131,14 @@ describe('gen-skill-docs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test(`every generated SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
const description = extractDescription(content);
|
||||
expect(description.length).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH);
|
||||
}
|
||||
});
|
||||
|
||||
test('generated files are fresh (match --dry-run)', () => {
|
||||
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--dry-run'], {
|
||||
cwd: ROOT,
|
||||
@@ -416,6 +457,150 @@ describe('REVIEW_DASHBOARD resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test Coverage Audit Resolver Tests ─────────────────────
|
||||
|
||||
describe('TEST_COVERAGE_AUDIT placeholders', () => {
|
||||
const planSkill = fs.readFileSync(path.join(ROOT, 'plan-eng-review', 'SKILL.md'), 'utf-8');
|
||||
const shipSkill = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('all three modes share codepath tracing methodology', () => {
|
||||
const sharedPhrases = [
|
||||
'Trace data flow',
|
||||
'Diagram the execution',
|
||||
'Quality scoring rubric',
|
||||
'★★★',
|
||||
'★★',
|
||||
'GAP',
|
||||
];
|
||||
for (const phrase of sharedPhrases) {
|
||||
expect(planSkill).toContain(phrase);
|
||||
expect(shipSkill).toContain(phrase);
|
||||
expect(reviewSkill).toContain(phrase);
|
||||
}
|
||||
// Plan mode traces the plan, not a git diff
|
||||
expect(planSkill).toContain('Trace every codepath in the plan');
|
||||
expect(planSkill).not.toContain('git diff origin');
|
||||
// Ship and review modes trace the diff
|
||||
expect(shipSkill).toContain('Trace every codepath changed');
|
||||
expect(reviewSkill).toContain('Trace every codepath changed');
|
||||
});
|
||||
|
||||
test('all three modes include E2E decision matrix', () => {
|
||||
for (const skill of [planSkill, shipSkill, reviewSkill]) {
|
||||
expect(skill).toContain('E2E Test Decision Matrix');
|
||||
expect(skill).toContain('→E2E');
|
||||
expect(skill).toContain('→EVAL');
|
||||
}
|
||||
});
|
||||
|
||||
test('all three modes include regression rule', () => {
|
||||
for (const skill of [planSkill, shipSkill, reviewSkill]) {
|
||||
expect(skill).toContain('REGRESSION RULE');
|
||||
expect(skill).toContain('IRON RULE');
|
||||
}
|
||||
});
|
||||
|
||||
test('all three modes include test framework detection', () => {
|
||||
for (const skill of [planSkill, shipSkill, reviewSkill]) {
|
||||
expect(skill).toContain('Test Framework Detection');
|
||||
expect(skill).toContain('CLAUDE.md');
|
||||
}
|
||||
});
|
||||
|
||||
test('plan mode adds tests to plan + includes test plan artifact', () => {
|
||||
expect(planSkill).toContain('Add missing tests to the plan');
|
||||
expect(planSkill).toContain('eng-review-test-plan');
|
||||
expect(planSkill).toContain('Test Plan Artifact');
|
||||
});
|
||||
|
||||
test('ship mode auto-generates tests + includes before/after count', () => {
|
||||
expect(shipSkill).toContain('Generate tests for uncovered paths');
|
||||
expect(shipSkill).toContain('Before/after test count');
|
||||
expect(shipSkill).toContain('30 code paths max');
|
||||
expect(shipSkill).toContain('ship-test-plan');
|
||||
});
|
||||
|
||||
test('review mode generates via Fix-First + gaps are INFORMATIONAL', () => {
|
||||
expect(reviewSkill).toContain('Fix-First');
|
||||
expect(reviewSkill).toContain('INFORMATIONAL');
|
||||
expect(reviewSkill).toContain('Step 4.75');
|
||||
expect(reviewSkill).toContain('subsumes the "Test Gaps" category');
|
||||
});
|
||||
|
||||
test('plan mode does NOT include ship-specific content', () => {
|
||||
expect(planSkill).not.toContain('Before/after test count');
|
||||
expect(planSkill).not.toContain('30 code paths max');
|
||||
expect(planSkill).not.toContain('ship-test-plan');
|
||||
});
|
||||
|
||||
test('review mode does NOT include test plan artifact', () => {
|
||||
expect(reviewSkill).not.toContain('Test Plan Artifact');
|
||||
expect(reviewSkill).not.toContain('eng-review-test-plan');
|
||||
expect(reviewSkill).not.toContain('ship-test-plan');
|
||||
});
|
||||
|
||||
// Regression guard: ship output contains key phrases from before the refactor
|
||||
test('ship SKILL.md regression guard — key phrases preserved', () => {
|
||||
const regressionPhrases = [
|
||||
'100% coverage is the goal',
|
||||
'ASCII coverage diagram',
|
||||
'processPayment',
|
||||
'refundPayment',
|
||||
'billing.test.ts',
|
||||
'checkout.e2e.ts',
|
||||
'COVERAGE:',
|
||||
'QUALITY:',
|
||||
'GAPS:',
|
||||
'Code paths:',
|
||||
'User flows:',
|
||||
];
|
||||
for (const phrase of regressionPhrases) {
|
||||
expect(shipSkill).toContain(phrase);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{TEST_FAILURE_TRIAGE}} resolver tests ---
|
||||
|
||||
describe('TEST_FAILURE_TRIAGE resolver', () => {
|
||||
const shipSkill = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('contains all 4 triage steps', () => {
|
||||
expect(shipSkill).toContain('Step T1: Classify each failure');
|
||||
expect(shipSkill).toContain('Step T2: Handle in-branch failures');
|
||||
expect(shipSkill).toContain('Step T3: Handle pre-existing failures');
|
||||
expect(shipSkill).toContain('Step T4: Execute the chosen action');
|
||||
});
|
||||
|
||||
test('T1 includes classification criteria (in-branch vs pre-existing)', () => {
|
||||
expect(shipSkill).toContain('In-branch');
|
||||
expect(shipSkill).toContain('Likely pre-existing');
|
||||
expect(shipSkill).toContain('git diff origin/');
|
||||
});
|
||||
|
||||
test('T3 branches on REPO_MODE (solo vs collaborative)', () => {
|
||||
expect(shipSkill).toContain('REPO_MODE');
|
||||
expect(shipSkill).toContain('solo');
|
||||
expect(shipSkill).toContain('collaborative');
|
||||
});
|
||||
|
||||
test('solo mode offers fix-now, TODO, and skip options', () => {
|
||||
expect(shipSkill).toContain('Investigate and fix now');
|
||||
expect(shipSkill).toContain('Add as P0 TODO');
|
||||
expect(shipSkill).toContain('Skip');
|
||||
});
|
||||
|
||||
test('collaborative mode offers blame + assign option', () => {
|
||||
expect(shipSkill).toContain('Blame + assign GitHub issue');
|
||||
expect(shipSkill).toContain('gh issue create');
|
||||
});
|
||||
|
||||
test('defaults ambiguous failures to in-branch (safety)', () => {
|
||||
expect(shipSkill).toContain('When ambiguous, default to in-branch');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{PLAN_FILE_REVIEW_REPORT}} resolver tests ---
|
||||
|
||||
describe('PLAN_FILE_REVIEW_REPORT resolver', () => {
|
||||
@@ -440,6 +625,20 @@ describe('PLAN_FILE_REVIEW_REPORT resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Plan status footer in preamble ---
|
||||
|
||||
describe('Plan status footer in preamble', () => {
|
||||
test('preamble contains plan status footer', () => {
|
||||
// Read any skill that uses PREAMBLE
|
||||
const content = fs.readFileSync(path.join(ROOT, 'office-hours', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Plan Status Footer');
|
||||
expect(content).toContain('GSTACK REVIEW REPORT');
|
||||
expect(content).toContain('gstack-review-read');
|
||||
expect(content).toContain('ExitPlanMode');
|
||||
expect(content).toContain('NO REVIEWS YET');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{SPEC_REVIEW_LOOP}} resolver tests ---
|
||||
|
||||
describe('SPEC_REVIEW_LOOP resolver', () => {
|
||||
@@ -506,6 +705,50 @@ describe('DESIGN_SKETCH resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{CODEX_SECOND_OPINION}} resolver tests ---
|
||||
|
||||
describe('CODEX_SECOND_OPINION resolver', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'office-hours', 'SKILL.md'), 'utf-8');
|
||||
const codexContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-office-hours', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('Phase 3.5 section appears in office-hours SKILL.md', () => {
|
||||
expect(content).toContain('Phase 3.5: Cross-Model Second Opinion');
|
||||
});
|
||||
|
||||
test('contains codex exec invocation', () => {
|
||||
expect(content).toContain('codex exec');
|
||||
});
|
||||
|
||||
test('contains opt-in AskUserQuestion text', () => {
|
||||
expect(content).toContain('second opinion from a different AI model');
|
||||
});
|
||||
|
||||
test('contains cross-model synthesis instructions', () => {
|
||||
expect(content).toMatch(/[Ss]ynthesis/);
|
||||
expect(content).toContain('Where Claude agrees with Codex');
|
||||
});
|
||||
|
||||
test('contains premise revision check', () => {
|
||||
expect(content).toContain('Codex challenged premise');
|
||||
});
|
||||
|
||||
test('contains error handling for auth, timeout, and empty', () => {
|
||||
expect(content).toMatch(/[Aa]uth.*fail/);
|
||||
expect(content).toMatch(/[Tt]imeout/);
|
||||
expect(content).toMatch(/[Ee]mpty response/);
|
||||
});
|
||||
|
||||
test('Codex host variant does NOT contain the Phase 3.5 resolver output', () => {
|
||||
// The resolver returns '' for codex host, so the interactive section is stripped.
|
||||
// Static template references to "Phase 3.5" in prose/conditionals are fine.
|
||||
// Other resolvers (design review lite) may contain CODEX_NOT_AVAILABLE, so we
|
||||
// check for Phase 3.5-specific markers only.
|
||||
expect(codexContent).not.toContain('Phase 3.5: Cross-Model Second Opinion');
|
||||
expect(codexContent).not.toContain('TMPERR_OH');
|
||||
expect(codexContent).not.toContain('gstack-codex-oh-');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{BENEFITS_FROM}} resolver tests ---
|
||||
|
||||
describe('BENEFITS_FROM resolver', () => {
|
||||
@@ -530,6 +773,126 @@ describe('BENEFITS_FROM resolver', () => {
|
||||
const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||||
expect(qaContent).not.toContain('Prerequisite Skill Offer');
|
||||
});
|
||||
|
||||
test('inline invocation — no "another window" language', () => {
|
||||
expect(ceoContent).not.toContain('another window');
|
||||
expect(engContent).not.toContain('another window');
|
||||
});
|
||||
|
||||
test('inline invocation — read-and-follow path present', () => {
|
||||
expect(ceoContent).toContain('office-hours/SKILL.md');
|
||||
expect(engContent).toContain('office-hours/SKILL.md');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{DESIGN_OUTSIDE_VOICES}} resolver tests ---
|
||||
|
||||
describe('DESIGN_OUTSIDE_VOICES resolver', () => {
|
||||
test('plan-design-review contains outside voices section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Outside Voices');
|
||||
expect(content).toContain('CODEX_AVAILABLE');
|
||||
expect(content).toContain('LITMUS SCORECARD');
|
||||
});
|
||||
|
||||
test('design-review contains outside voices section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Outside Voices');
|
||||
expect(content).toContain('source audit');
|
||||
});
|
||||
|
||||
test('design-consultation contains outside voices section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-consultation', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Outside Voices');
|
||||
expect(content).toContain('design direction');
|
||||
});
|
||||
|
||||
test('branches correctly per skillName — different prompts', () => {
|
||||
const planContent = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
const consultContent = fs.readFileSync(path.join(ROOT, 'design-consultation', 'SKILL.md'), 'utf-8');
|
||||
// plan-design-review uses analytical prompt (high reasoning)
|
||||
expect(planContent).toContain('model_reasoning_effort="high"');
|
||||
// design-consultation uses creative prompt (medium reasoning)
|
||||
expect(consultContent).toContain('model_reasoning_effort="medium"');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{DESIGN_HARD_RULES}} resolver tests ---
|
||||
|
||||
describe('DESIGN_HARD_RULES resolver', () => {
|
||||
test('plan-design-review Pass 4 contains hard rules', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Hard Rules');
|
||||
expect(content).toContain('Classifier');
|
||||
expect(content).toContain('MARKETING/LANDING PAGE');
|
||||
expect(content).toContain('APP UI');
|
||||
});
|
||||
|
||||
test('design-review contains hard rules', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Hard Rules');
|
||||
});
|
||||
|
||||
test('includes all 3 rule sets', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Landing page rules');
|
||||
expect(content).toContain('App UI rules');
|
||||
expect(content).toContain('Universal rules');
|
||||
});
|
||||
|
||||
test('references shared AI slop blacklist items', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('3-column feature grid');
|
||||
expect(content).toContain('Purple/violet/indigo');
|
||||
});
|
||||
|
||||
test('includes OpenAI hard rejection criteria', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Generic SaaS card grid');
|
||||
expect(content).toContain('Carousel with no narrative purpose');
|
||||
});
|
||||
|
||||
test('includes OpenAI litmus checks', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Brand/product unmistakable');
|
||||
expect(content).toContain('premium with all decorative shadows removed');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Extended DESIGN_SKETCH resolver tests ---
|
||||
|
||||
describe('DESIGN_SKETCH extended with outside voices', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'office-hours', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('contains outside design voices step', () => {
|
||||
expect(content).toContain('Outside design voices');
|
||||
});
|
||||
|
||||
test('offers opt-in via AskUserQuestion', () => {
|
||||
expect(content).toContain('outside design perspectives');
|
||||
});
|
||||
|
||||
test('still contains original wireframe steps', () => {
|
||||
expect(content).toContain('wireframe');
|
||||
expect(content).toContain('$B goto');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Extended DESIGN_REVIEW_LITE resolver tests ---
|
||||
|
||||
describe('DESIGN_REVIEW_LITE extended with Codex', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('contains Codex design voice block', () => {
|
||||
expect(content).toContain('Codex design voice');
|
||||
expect(content).toContain('CODEX (design)');
|
||||
});
|
||||
|
||||
test('still contains original checklist steps', () => {
|
||||
expect(content).toContain('design-checklist.md');
|
||||
expect(content).toContain('SCOPE_FRONTEND');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Codex Generation Tests ─────────────────────────────────
|
||||
@@ -537,6 +900,11 @@ describe('BENEFITS_FROM resolver', () => {
|
||||
describe('Codex generation (--host codex)', () => {
|
||||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||||
|
||||
// .agents/ is gitignored (v0.11.2.0) — generate on demand for tests
|
||||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
|
||||
// Dynamic discovery of expected Codex skills: all templates except /codex
|
||||
const CODEX_SKILLS = (() => {
|
||||
const skills: Array<{ dir: string; codexName: string }> = [];
|
||||
@@ -611,11 +979,11 @@ describe('Codex generation (--host codex)', () => {
|
||||
test('Codex review step stripped from Codex-host ship and review', () => {
|
||||
const shipContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(shipContent).not.toContain('codex review --base');
|
||||
expect(shipContent).not.toContain('Investigate and fix');
|
||||
expect(shipContent).not.toContain('CODEX_REVIEWS');
|
||||
|
||||
const reviewContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
expect(reviewContent).not.toContain('codex review --base');
|
||||
expect(reviewContent).not.toContain('Investigate and fix');
|
||||
expect(reviewContent).not.toContain('CODEX_REVIEWS');
|
||||
});
|
||||
|
||||
test('--host codex --dry-run freshness', () => {
|
||||
@@ -683,11 +1051,14 @@ describe('Codex generation (--host codex)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex preamble uses codex paths', () => {
|
||||
test('Codex preamble resolves runtime assets from repo-local or global gstack roots', () => {
|
||||
// Check a skill that has a preamble (review is a good candidate)
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('~/.codex/skills/gstack');
|
||||
expect(content).toContain('.agents/skills/gstack');
|
||||
expect(content).toContain('GSTACK_ROOT');
|
||||
expect(content).toContain('$_ROOT/.agents/skills/gstack');
|
||||
expect(content).toContain('$GSTACK_BIN/gstack-config');
|
||||
expect(content).toContain('$GSTACK_ROOT/gstack-upgrade/SKILL.md');
|
||||
expect(content).not.toContain('~/.codex/skills/gstack/bin/gstack-config get telemetry');
|
||||
});
|
||||
|
||||
// ─── Path rewriting regression tests ─────────────────────────
|
||||
@@ -725,9 +1096,9 @@ describe('Codex generation (--host codex)', () => {
|
||||
// Test each of the 4 path rewrite rules individually
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
|
||||
// Rule 1: ~/.claude/skills/gstack → ~/.codex/skills/gstack
|
||||
// Rule 1: ~/.claude/skills/gstack → $GSTACK_ROOT
|
||||
expect(content).not.toContain('~/.claude/skills/gstack');
|
||||
expect(content).toContain('~/.codex/skills/gstack');
|
||||
expect(content).toContain('$GSTACK_ROOT');
|
||||
|
||||
// Rule 2: .claude/skills/gstack → .agents/skills/gstack
|
||||
expect(content).not.toContain('.claude/skills/gstack');
|
||||
@@ -746,6 +1117,9 @@ describe('Codex generation (--host codex)', () => {
|
||||
// No skill should reference Claude paths
|
||||
expect(content).not.toContain('~/.claude/skills');
|
||||
expect(content).not.toContain('.claude/skills');
|
||||
if (content.includes('gstack-config') || content.includes('gstack-update-check') || content.includes('gstack-telemetry-log')) {
|
||||
expect(content).toContain('$GSTACK_ROOT');
|
||||
}
|
||||
// If a skill references checklist.md, it must use the correct sidecar path
|
||||
if (content.includes('checklist.md') && !content.includes('design-checklist.md')) {
|
||||
expect(content).not.toContain('gstack-review/checklist.md');
|
||||
@@ -776,9 +1150,24 @@ describe('Codex generation (--host codex)', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
expect(content).not.toContain('~/.codex/');
|
||||
expect(content).not.toContain('.agents/skills');
|
||||
// gstack-upgrade legitimately references .agents/skills for cross-platform detection
|
||||
if (skill.dir !== 'gstack-upgrade') {
|
||||
expect(content).not.toContain('.agents/skills');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Design outside voices: Codex host guard ─────────────────
|
||||
|
||||
test('codex host produces empty outside voices in design-review', () => {
|
||||
const codexContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(codexContent).not.toContain('Design Outside Voices');
|
||||
});
|
||||
|
||||
test('codex host does not include Codex design block in ship', () => {
|
||||
const codexContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(codexContent).not.toContain('Codex design voice');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Setup script validation ─────────────────────────────────
|
||||
@@ -812,8 +1201,31 @@ describe('setup script validation', () => {
|
||||
setupContent.indexOf('# 5. Install for Codex'),
|
||||
setupContent.indexOf('# 6. Create')
|
||||
);
|
||||
expect(codexSection).toContain('create_codex_runtime_root');
|
||||
expect(codexSection).toContain('link_codex_skill_dirs');
|
||||
expect(codexSection).not.toContain('link_claude_skill_dirs');
|
||||
expect(codexSection).not.toContain('ln -snf "$GSTACK_DIR" "$CODEX_GSTACK"');
|
||||
});
|
||||
|
||||
test('Codex install prefers repo-local .agents/skills when setup runs from there', () => {
|
||||
expect(setupContent).toContain('SKILLS_PARENT_BASENAME');
|
||||
expect(setupContent).toContain('CODEX_REPO_LOCAL=0');
|
||||
expect(setupContent).toContain('[ "$SKILLS_PARENT_BASENAME" = ".agents" ]');
|
||||
expect(setupContent).toContain('CODEX_REPO_LOCAL=1');
|
||||
expect(setupContent).toContain('CODEX_SKILLS="$INSTALL_SKILLS_DIR"');
|
||||
});
|
||||
|
||||
test('setup separates install path from source path for symlinked repo-local installs', () => {
|
||||
expect(setupContent).toContain('INSTALL_GSTACK_DIR=');
|
||||
expect(setupContent).toContain('SOURCE_GSTACK_DIR=');
|
||||
expect(setupContent).toContain('INSTALL_SKILLS_DIR=');
|
||||
expect(setupContent).toContain('CODEX_GSTACK="$INSTALL_GSTACK_DIR"');
|
||||
expect(setupContent).toContain('link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS"');
|
||||
});
|
||||
|
||||
test('Codex installs always create sidecar runtime assets for the real skill target', () => {
|
||||
expect(setupContent).toContain('if [ "$INSTALL_CODEX" -eq 1 ]; then');
|
||||
expect(setupContent).toContain('create_agents_sidecar "$SOURCE_GSTACK_DIR"');
|
||||
});
|
||||
|
||||
test('link_codex_skill_dirs reads from .agents/skills/', () => {
|
||||
@@ -833,14 +1245,40 @@ describe('setup script validation', () => {
|
||||
expect(fnBody).toContain('ln -snf "gstack/$skill_name"');
|
||||
});
|
||||
|
||||
test('setup supports --host auto|claude|codex', () => {
|
||||
test('setup supports --host auto|claude|codex|kiro', () => {
|
||||
expect(setupContent).toContain('--host');
|
||||
expect(setupContent).toContain('claude|codex|auto');
|
||||
expect(setupContent).toContain('claude|codex|kiro|auto');
|
||||
});
|
||||
|
||||
test('auto mode detects claude and codex binaries', () => {
|
||||
test('auto mode detects claude, codex, and kiro binaries', () => {
|
||||
expect(setupContent).toContain('command -v claude');
|
||||
expect(setupContent).toContain('command -v codex');
|
||||
expect(setupContent).toContain('command -v kiro-cli');
|
||||
});
|
||||
|
||||
// T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill
|
||||
test('link_codex_skill_dirs skips the gstack sidecar directory', () => {
|
||||
const fnStart = setupContent.indexOf('link_codex_skill_dirs()');
|
||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('[ "$skill_name" = "gstack" ] && continue');
|
||||
});
|
||||
|
||||
// T2: Dynamic $GSTACK_ROOT paths in generated Codex preambles
|
||||
test('generated Codex preambles use dynamic GSTACK_ROOT paths', () => {
|
||||
const codexSkillDir = path.join(ROOT, '.agents', 'skills', 'gstack-ship');
|
||||
if (!fs.existsSync(codexSkillDir)) return; // skip if .agents/ not generated
|
||||
const content = fs.readFileSync(path.join(codexSkillDir, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('GSTACK_ROOT=');
|
||||
expect(content).toContain('$GSTACK_BIN/');
|
||||
});
|
||||
|
||||
// T3: Kiro host support in setup script
|
||||
test('setup supports --host kiro with install section and sed rewrites', () => {
|
||||
expect(setupContent).toContain('INSTALL_KIRO=');
|
||||
expect(setupContent).toContain('kiro-cli');
|
||||
expect(setupContent).toContain('KIRO_SKILLS=');
|
||||
expect(setupContent).toContain('~/.kiro/skills/gstack');
|
||||
});
|
||||
|
||||
test('create_agents_sidecar links runtime assets', () => {
|
||||
@@ -853,6 +1291,28 @@ describe('setup script validation', () => {
|
||||
expect(fnBody).toContain('review');
|
||||
expect(fnBody).toContain('qa');
|
||||
});
|
||||
|
||||
test('create_codex_runtime_root exposes only runtime assets', () => {
|
||||
const fnStart = setupContent.indexOf('create_codex_runtime_root()');
|
||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart)));
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('gstack/SKILL.md');
|
||||
expect(fnBody).toContain('browse/dist');
|
||||
expect(fnBody).toContain('browse/bin');
|
||||
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
|
||||
// Review runtime assets (individual files, not the whole dir)
|
||||
expect(fnBody).toContain('checklist.md');
|
||||
expect(fnBody).toContain('design-checklist.md');
|
||||
expect(fnBody).toContain('greptile-triage.md');
|
||||
expect(fnBody).toContain('TODOS-format.md');
|
||||
expect(fnBody).not.toContain('ln -snf "$gstack_dir" "$codex_gstack"');
|
||||
});
|
||||
|
||||
test('direct Codex installs are migrated out of ~/.codex/skills/gstack', () => {
|
||||
expect(setupContent).toContain('migrate_direct_codex_install');
|
||||
expect(setupContent).toContain('$HOME/.gstack/repos/gstack');
|
||||
expect(setupContent).toContain('avoid duplicate skill discovery');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
// Import normalizeRemoteUrl for unit testing
|
||||
// We test the script end-to-end via CLI and normalizeRemoteUrl via import
|
||||
const scriptPath = join(import.meta.dir, "..", "bin", "gstack-global-discover.ts");
|
||||
|
||||
describe("gstack-global-discover", () => {
|
||||
describe("normalizeRemoteUrl", () => {
|
||||
// Dynamically import to test the exported function
|
||||
let normalizeRemoteUrl: (url: string) => string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../bin/gstack-global-discover.ts");
|
||||
normalizeRemoteUrl = mod.normalizeRemoteUrl;
|
||||
});
|
||||
|
||||
test("strips .git suffix", () => {
|
||||
expect(normalizeRemoteUrl("https://github.com/user/repo.git")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts SSH to HTTPS", () => {
|
||||
expect(normalizeRemoteUrl("git@github.com:user/repo.git")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts SSH without .git to HTTPS", () => {
|
||||
expect(normalizeRemoteUrl("git@github.com:user/repo")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("lowercases host", () => {
|
||||
expect(normalizeRemoteUrl("https://GitHub.COM/user/repo")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("SSH and HTTPS for same repo normalize to same URL", () => {
|
||||
const ssh = normalizeRemoteUrl("git@github.com:garrytan/gstack.git");
|
||||
const https = normalizeRemoteUrl("https://github.com/garrytan/gstack.git");
|
||||
const httpsNoDotGit = normalizeRemoteUrl("https://github.com/garrytan/gstack");
|
||||
expect(ssh).toBe(https);
|
||||
expect(https).toBe(httpsNoDotGit);
|
||||
});
|
||||
|
||||
test("handles local: URLs consistently", () => {
|
||||
const result = normalizeRemoteUrl("local:/tmp/my-repo");
|
||||
// local: gets parsed as a URL scheme — the important thing is consistency
|
||||
expect(result).toContain("/tmp/my-repo");
|
||||
});
|
||||
|
||||
test("handles GitLab SSH URLs", () => {
|
||||
expect(normalizeRemoteUrl("git@gitlab.com:org/project.git")).toBe(
|
||||
"https://gitlab.com/org/project"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI", () => {
|
||||
test("--help exits 0 and prints usage", () => {
|
||||
const result = spawnSync("bun", ["run", scriptPath, "--help"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("--since");
|
||||
});
|
||||
|
||||
test("no args exits 1 with error", () => {
|
||||
const result = spawnSync("bun", ["run", scriptPath], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("--since is required");
|
||||
});
|
||||
|
||||
test("invalid window format exits 1", () => {
|
||||
const result = spawnSync("bun", ["run", scriptPath, "--since", "abc"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Invalid window format");
|
||||
});
|
||||
|
||||
test("--since 7d produces valid JSON", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "7d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json).toHaveProperty("window", "7d");
|
||||
expect(json).toHaveProperty("repos");
|
||||
expect(json).toHaveProperty("total_sessions");
|
||||
expect(json).toHaveProperty("total_repos");
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.repos)).toBe(true);
|
||||
});
|
||||
|
||||
test("--since 7d --format summary produces readable output", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "7d", "--format", "summary"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Window: 7d");
|
||||
expect(result.stdout).toContain("Sessions:");
|
||||
expect(result.stdout).toContain("Repos:");
|
||||
});
|
||||
|
||||
test("--since 1h returns results (may be empty)", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "1h", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.total_sessions).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery output structure", () => {
|
||||
test("repos have required fields", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
|
||||
for (const repo of json.repos) {
|
||||
expect(repo).toHaveProperty("name");
|
||||
expect(repo).toHaveProperty("remote");
|
||||
expect(repo).toHaveProperty("paths");
|
||||
expect(repo).toHaveProperty("sessions");
|
||||
expect(Array.isArray(repo.paths)).toBe(true);
|
||||
expect(repo.paths.length).toBeGreaterThan(0);
|
||||
expect(repo.sessions).toHaveProperty("claude_code");
|
||||
expect(repo.sessions).toHaveProperty("codex");
|
||||
expect(repo.sessions).toHaveProperty("gemini");
|
||||
}
|
||||
});
|
||||
|
||||
test("tools summary matches repo data", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
const json = JSON.parse(result.stdout);
|
||||
|
||||
// Total sessions should equal sum across tools
|
||||
const toolTotal =
|
||||
json.tools.claude_code.total_sessions +
|
||||
json.tools.codex.total_sessions +
|
||||
json.tools.gemini.total_sessions;
|
||||
expect(json.total_sessions).toBe(toolTotal);
|
||||
});
|
||||
|
||||
test("deduplicates Conductor workspaces by remote", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
const json = JSON.parse(result.stdout);
|
||||
|
||||
// Check that no two repos share the same normalized remote
|
||||
const remotes = json.repos.map((r: any) => r.remote);
|
||||
const uniqueRemotes = new Set(remotes);
|
||||
expect(remotes.length).toBe(uniqueRemotes.size);
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
-10
@@ -70,7 +70,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'plan-eng-review-artifact': ['plan-eng-review/**'],
|
||||
|
||||
// Ship
|
||||
'ship-base-branch': ['ship/**'],
|
||||
'ship-base-branch': ['ship/**', 'bin/gstack-repo-mode'],
|
||||
'ship-local-workflow': ['ship/**', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
// Setup browser cookies
|
||||
@@ -80,6 +80,9 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'retro': ['retro/**'],
|
||||
'retro-base-branch': ['retro/**'],
|
||||
|
||||
// Global discover
|
||||
'global-discover': ['bin/gstack-global-discover.ts', 'test/global-discover.test.ts'],
|
||||
|
||||
// Document-release
|
||||
'document-release': ['document-release/**'],
|
||||
|
||||
@@ -95,17 +98,20 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'gemini-review-findings': ['review/**', '.agents/skills/gstack-review/**', 'test/helpers/gemini-session-runner.ts'],
|
||||
|
||||
|
||||
// Ship coverage audit
|
||||
'ship-coverage-audit': ['ship/**'],
|
||||
// Coverage audit (shared fixture) + triage
|
||||
'ship-coverage-audit': ['ship/**', 'test/fixtures/coverage-audit-fixture.ts', 'bin/gstack-repo-mode'],
|
||||
'review-coverage-audit': ['review/**', 'test/fixtures/coverage-audit-fixture.ts'],
|
||||
'plan-eng-coverage-audit': ['plan-eng-review/**', 'test/fixtures/coverage-audit-fixture.ts'],
|
||||
'ship-triage': ['ship/**', 'bin/gstack-repo-mode'],
|
||||
|
||||
// Design
|
||||
'design-consultation-core': ['design-consultation/**'],
|
||||
'design-consultation-existing': ['design-consultation/**'],
|
||||
'design-consultation-research': ['design-consultation/**'],
|
||||
'design-consultation-preview': ['design-consultation/**'],
|
||||
'plan-design-review-plan-mode': ['plan-design-review/**'],
|
||||
'plan-design-review-no-ui-scope': ['plan-design-review/**'],
|
||||
'design-review-fix': ['design-review/**', 'browse/src/**'],
|
||||
'design-consultation-core': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-consultation-existing': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-consultation-research': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-consultation-preview': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'plan-design-review-plan-mode': ['plan-design-review/**', 'scripts/gen-skill-docs.ts'],
|
||||
'plan-design-review-no-ui-scope': ['plan-design-review/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-review-fix': ['design-review/**', 'browse/src/**', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
// gstack-upgrade
|
||||
'gstack-upgrade-happy-path': ['gstack-upgrade/**'],
|
||||
@@ -116,6 +122,9 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'benchmark-workflow': ['benchmark/**', 'browse/src/**'],
|
||||
'setup-deploy-workflow': ['setup-deploy/**', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
// Autoplan
|
||||
'autoplan-core': ['autoplan/**', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**'],
|
||||
|
||||
// Skill routing — journey-stage tests (depend on ALL skill descriptions)
|
||||
'journey-ideation': ['*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'],
|
||||
'journey-plan-eng': ['*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,20 @@ describe('SKILL.md command validation', () => {
|
||||
const result = validateSkill(skill);
|
||||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('all $B commands in autoplan/SKILL.md are valid browse commands', () => {
|
||||
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
||||
if (!fs.existsSync(skill)) return;
|
||||
const result = validateSkill(skill);
|
||||
expect(result.invalid).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('all snapshot flags in autoplan/SKILL.md are valid', () => {
|
||||
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
||||
if (!fs.existsSync(skill)) return;
|
||||
const result = validateSkill(skill);
|
||||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command registry consistency', () => {
|
||||
@@ -227,6 +241,7 @@ describe('Update check preamble', () => {
|
||||
'benchmark/SKILL.md',
|
||||
'land-and-deploy/SKILL.md',
|
||||
'setup-deploy/SKILL.md',
|
||||
'cso/SKILL.md',
|
||||
];
|
||||
|
||||
for (const skill of skillsWithUpdateCheck) {
|
||||
@@ -543,6 +558,7 @@ describe('v0.4.1 preamble features', () => {
|
||||
'benchmark/SKILL.md',
|
||||
'land-and-deploy/SKILL.md',
|
||||
'setup-deploy/SKILL.md',
|
||||
'cso/SKILL.md',
|
||||
];
|
||||
|
||||
for (const skill of skillsWithPreamble) {
|
||||
@@ -821,7 +837,7 @@ describe('Completeness Principle in generated SKILL.md files', () => {
|
||||
'design-review/SKILL.md',
|
||||
'design-consultation/SKILL.md',
|
||||
'document-release/SKILL.md',
|
||||
];
|
||||
'cso/SKILL.md', ];
|
||||
|
||||
for (const skill of skillsWithPreamble) {
|
||||
test(`${skill} contains Completeness Principle section`, () => {
|
||||
@@ -979,6 +995,34 @@ describe('gstack-slug', () => {
|
||||
expect(lines[0]).toMatch(/^SLUG=.+/);
|
||||
expect(lines[1]).toMatch(/^BRANCH=.+/);
|
||||
});
|
||||
|
||||
test('output values contain only safe characters (no shell metacharacters)', () => {
|
||||
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||||
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
||||
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
||||
// Only alphanumeric, dot, dash, underscore are allowed (#133)
|
||||
expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/);
|
||||
expect(branch).toMatch(/^[a-zA-Z0-9._-]+$/);
|
||||
});
|
||||
test('eval sets variables under bash with set -euo pipefail', () => {
|
||||
const result = Bun.spawnSync(
|
||||
['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
expect(output).toMatch(/^SLUG=.+/m);
|
||||
expect(output).toMatch(/^BRANCH=.+/m);
|
||||
});
|
||||
|
||||
test('no templates or bin scripts use source process substitution for gstack-slug', () => {
|
||||
const result = Bun.spawnSync(
|
||||
['grep', '-r', 'source <(.*gstack-slug', '--include=*.tmpl', '--include=gstack-review-*', '.'],
|
||||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }
|
||||
);
|
||||
// grep returns exit code 1 when no matches found — that's what we want
|
||||
expect(result.stdout.toString().trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Test Bootstrap validation ---
|
||||
@@ -1303,12 +1347,18 @@ describe('Codex skill', () => {
|
||||
});
|
||||
|
||||
test('codex-host ship/review do NOT contain adversarial review step', () => {
|
||||
// .agents/ is gitignored — generate on demand
|
||||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
const shipContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(shipContent).not.toContain('codex review --base');
|
||||
expect(shipContent).not.toContain('Investigate and fix');
|
||||
expect(shipContent).not.toContain('CODEX_REVIEWS');
|
||||
|
||||
const reviewContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
expect(reviewContent).not.toContain('codex review --base');
|
||||
expect(reviewContent).not.toContain('codex_reviews');
|
||||
expect(reviewContent).not.toContain('CODEX_REVIEWS');
|
||||
expect(reviewContent).not.toContain('adversarial-review');
|
||||
expect(reviewContent).not.toContain('Investigate and fix');
|
||||
});
|
||||
@@ -1375,6 +1425,11 @@ describe('Skill trigger phrases', () => {
|
||||
describe('Codex skill validation', () => {
|
||||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||||
|
||||
// .agents/ is gitignored (v0.11.2.0) — generate on demand for tests
|
||||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
|
||||
// Discover all Claude skills with templates (except /codex which is Claude-only)
|
||||
const CLAUDE_SKILLS_WITH_TEMPLATES = (() => {
|
||||
const skills: string[] = [];
|
||||
@@ -1436,3 +1491,58 @@ describe('Codex skill validation', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Repo mode and test failure triage validation ---
|
||||
|
||||
describe('Repo mode preamble validation', () => {
|
||||
test('generated SKILL.md preamble contains REPO_MODE output', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('REPO_MODE:');
|
||||
expect(content).toContain('gstack-repo-mode');
|
||||
});
|
||||
|
||||
test('generated SKILL.md contains See Something Say Something section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('See Something, Say Something');
|
||||
expect(content).toContain('REPO_MODE');
|
||||
expect(content).toContain('solo');
|
||||
expect(content).toContain('collaborative');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test failure triage in ship skill', () => {
|
||||
test('ship/SKILL.md contains Test Failure Ownership Triage', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Test Failure Ownership Triage');
|
||||
});
|
||||
|
||||
test('ship/SKILL.md triage uses git diff for classification', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('git diff origin/<base>...HEAD --name-only');
|
||||
});
|
||||
|
||||
test('ship/SKILL.md triage has solo and collaborative paths', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('REPO_MODE');
|
||||
expect(content).toContain('solo');
|
||||
expect(content).toContain('collaborative');
|
||||
expect(content).toContain('Investigate and fix now');
|
||||
expect(content).toContain('Add as P0 TODO');
|
||||
});
|
||||
|
||||
test('ship/SKILL.md triage has GitHub issue assignment for collaborative mode', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('gh issue create');
|
||||
expect(content).toContain('--assignee');
|
||||
});
|
||||
|
||||
test('{{TEST_FAILURE_TRIAGE}} placeholder is fully resolved in ship/SKILL.md', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).not.toContain('{{TEST_FAILURE_TRIAGE}}');
|
||||
});
|
||||
|
||||
test('ship/SKILL.md uses in-branch language for stop condition', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('In-branch test failures');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,8 +79,9 @@ describe('selectTests', () => {
|
||||
expect(result.selected).toContain('plan-ceo-review');
|
||||
expect(result.selected).toContain('plan-ceo-review-selective');
|
||||
expect(result.selected).toContain('plan-ceo-review-benefits');
|
||||
expect(result.selected.length).toBe(3);
|
||||
expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length - 3);
|
||||
expect(result.selected).toContain('autoplan-core');
|
||||
expect(result.selected.length).toBe(4);
|
||||
expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length - 4);
|
||||
});
|
||||
|
||||
test('global touchfile triggers ALL tests', () => {
|
||||
|
||||
Reference in New Issue
Block a user