From 83809afcd379a83bcad132b6a947c301d5864395 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:10:29 -0500 Subject: [PATCH 1/4] feat(bootstrap): add --mode lean for fast, install-safe project onboarding (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skips migrate, install, and populate phases. Install failure can no longer produce a `partial` status — lean mode emits a deterministic install command as the first required next step instead. Fully compatible with --yes, --ci, --force, and --security-sensitive. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/__tests__/bootstrap.test.ts | 83 ++++++++++++++ packages/cli/src/commands/bootstrap.ts | 109 ++++++++++++------- packages/cli/src/index.ts | 3 +- 3 files changed, 155 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index a9a993c..00b3596 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -301,4 +301,87 @@ load state.adf always const hasHint = installStep.warnings.some((w: string) => w.includes('--no-frozen-lockfile')); expect(hasHint).toBe(true); }); + + describe('--mode lean', () => { + it('skips migrate, install, and populate phases (status: skip)', async () => { + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + const migrateStep = report.steps.find((s: { name: string }) => s.name === 'migrate'); + const installStep = report.steps.find((s: { name: string }) => s.name === 'install'); + const populateStep = report.steps.find((s: { name: string }) => s.name === 'populate'); + expect(migrateStep.status).toBe('skip'); + expect(installStep.status).toBe('skip'); + expect(populateStep.status).toBe('skip'); + }); + + it('exits with status success even when install would have failed', async () => { + execSyncOverride = () => { + throw new Error('ERR_PNPM_FROZEN_LOCKFILE: Lockfile is not up-to-date'); + }; + + logs = []; + try { + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + } finally { + execSyncOverride = null; + } + + const report = JSON.parse(logs[0]); + expect(report.status).toBe('success'); + const installStep = report.steps.find((s: { name: string }) => s.name === 'install'); + expect(installStep.status).toBe('skip'); + }); + + it('emits the detected package manager install command as the first required next step', async () => { + fs.writeFileSync('pnpm-lock.yaml', ''); + + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + expect(report.nextSteps.length).toBe(4); + const first = report.nextSteps[0]; + expect(first.cmd).toBe('pnpm install'); + expect(first.required).toBe(true); + }); + + it('emits exactly 4 deterministic next steps in the correct order', async () => { + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + expect(report.nextSteps.length).toBe(4); + expect(report.nextSteps[0].cmd).toMatch(/install$/); + expect(report.nextSteps[1].cmd).toBe('charter hook install --commit-msg'); + expect(report.nextSteps[2].cmd).toBe('charter hook install --pre-commit'); + expect(report.nextSteps[3].cmd).toBe('charter serve'); + }); + + it('--mode lean combined with --skip-install is not an error', async () => { + logs = []; + const exitCode = await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-install', '--skip-doctor'], + ); + + expect(exitCode).toBe(0); + const report = JSON.parse(logs[0]); + const installStep = report.steps.find((s: { name: string }) => s.name === 'install'); + expect(installStep.status).toBe('skip'); + }); + }); }); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 1085955..5fa6185 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -82,6 +82,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro const securitySensitive = args.includes('--security-sensitive'); const nonInteractive = options.yes; const setupOverwrite = options.yes || force; + const leanMode = getFlag(args, '--mode') === 'lean'; if (ciTarget && ciTarget !== 'github') { throw new CLIError(`Unsupported CI target: ${ciTarget}. Supported: github`); @@ -121,7 +122,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro } if (options.format === 'text') { - console.log('[1/7] Detecting stack...'); + console.log(`[1/${leanMode ? '4' : '7'}] Detecting stack...`); console.log(` Stack: ${selectedPreset} (${detection.confidence} confidence)`); console.log(` Monorepo: ${detection.monorepo ? 'yes' : 'no'}${detection.monorepo && detection.signals.hasPnpm ? ' (pnpm workspace)' : ''}`); if (detection.warnings.length > 0) { @@ -145,7 +146,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro warnings += setupResult.step.warnings.length; if (options.format === 'text') { - console.log('[2/7] Setting up governance...'); + console.log(`[2/${leanMode ? '4' : '7'}] Setting up governance...`); for (const f of (setupResult.step.details.created as string[] || [])) { console.log(` Created ${f}`); } @@ -163,7 +164,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro warnings += adfResult.step.warnings.length; if (options.format === 'text') { - console.log('[3/7] Initializing ADF context...'); + console.log(`[3/${leanMode ? '4' : '7'}] Initializing ADF context...`); for (const f of (adfResult.step.details.files as string[] || [])) { console.log(` Created ${f}`); } @@ -234,11 +235,13 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 4: Migrate Agent Configs // ======================================================================== - const migrateResult = runMigratePhase(options, nonInteractive); + const migrateResult = leanMode + ? { step: { name: 'migrate' as StepName, status: 'skip' as StepStatus, details: { reason: 'lean mode' }, warnings: [] as string[] } } + : runMigratePhase(options, nonInteractive); result.steps.push(migrateResult.step); warnings += migrateResult.step.warnings.length; - if (options.format === 'text') { + if (options.format === 'text' && !leanMode) { console.log('[4/7] Migrating agent configs...'); if (migrateResult.step.status === 'skip') { console.log(' Skipped (no migratable files)'); @@ -256,11 +259,13 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 5: Install // ======================================================================== - const installResult = runInstallPhase(options, skipInstall); + const installResult = leanMode + ? { step: { name: 'install' as StepName, status: 'skip' as StepStatus, details: { reason: 'lean mode' }, warnings: [] as string[] } } + : runInstallPhase(options, skipInstall); result.steps.push(installResult.step); warnings += installResult.step.warnings.length; - if (options.format === 'text') { + if (options.format === 'text' && !leanMode) { console.log('[5/7] Installing dependencies...'); if (skipInstall) { console.log(' Skipped (--skip-install)'); @@ -285,11 +290,13 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // ======================================================================== // Phase 6: Populate (#89) // ======================================================================== - const populateResult = await runPopulatePhase(options); + const populateResult = leanMode + ? { step: { name: 'populate' as StepName, status: 'skip' as StepStatus, details: { reason: 'lean mode' }, warnings: [] as string[] } } + : await runPopulatePhase(options); result.steps.push(populateResult.step); warnings += populateResult.step.warnings.length; - if (options.format === 'text') { + if (options.format === 'text' && !leanMode) { console.log('[6/7] Auto-populating ADF modules...'); const populated = populateResult.step.details.populated as number; const skipped = populateResult.step.details.skipped as number; @@ -309,7 +316,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro warnings += doctorResult.step.warnings.length; if (options.format === 'text') { - console.log('[7/7] Running health check...'); + console.log(`[${leanMode ? '4/4' : '7/7'}] Running health check...`); if (skipDoctor) { console.log(' Skipped (--skip-doctor)'); } else { @@ -328,47 +335,71 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro const failCount = result.steps.filter(s => s.status === 'fail').length; result.status = failCount === 0 ? 'success' : failCount < result.steps.length ? 'partial' : 'failure'; - // Build next steps - result.nextSteps.push({ - cmd: 'charter serve # start MCP server for Claude Code / Codex / Cursor integration', - required: false, - reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', - }); - result.nextSteps.push({ - cmd: 'Review .charter/patterns/ and customize for your stack', - required: false, - reason: 'Customize blessed stack patterns', - }); - result.nextSteps.push({ - cmd: 'git add .charter .ai CLAUDE.md .cursorrules agents.md && git commit -m "chore: bootstrap charter governance"', - required: false, - reason: 'Commit governance baseline', - }); - - // Gate hook next-steps on being inside a git repo - if (inGitRepo) { + if (leanMode) { + const leanPm = detectPackageManagerFromLockfiles(); + result.nextSteps.push({ + cmd: `${leanPm} install`, + required: true, + reason: 'Install dependencies (skipped in lean mode)', + }); + result.nextSteps.push({ + cmd: 'charter hook install --commit-msg', + required: false, + reason: 'Install commit-msg hook for trailer enforcement', + }); result.nextSteps.push({ cmd: 'charter hook install --pre-commit', required: false, reason: 'Install pre-commit hook for ADF evidence gate', }); result.nextSteps.push({ - cmd: 'charter hook install --commit-msg', + cmd: 'charter serve', required: false, - reason: 'Install commit-msg hook for trailer enforcement', + reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', }); + } else { + // Build next steps result.nextSteps.push({ - cmd: 'echo \'charter context --write\' >> .git/hooks/post-commit && chmod +x .git/hooks/post-commit', + cmd: 'charter serve # start MCP server for Claude Code / Codex / Cursor integration', required: false, - reason: 'Keep .charter/context.md fresh after each commit (charter brief auto-refresh)', + reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', + }); + result.nextSteps.push({ + cmd: 'Review .charter/patterns/ and customize for your stack', + required: false, + reason: 'Customize blessed stack patterns', + }); + result.nextSteps.push({ + cmd: 'git add .charter .ai CLAUDE.md .cursorrules agents.md && git commit -m "chore: bootstrap charter governance"', + required: false, + reason: 'Commit governance baseline', }); - } - result.nextSteps.push({ - cmd: 'charter hook print --claude # paste output into .claude/settings.json → hooks.UserPromptSubmit', - required: false, - reason: 'Auto-refresh context at session start so charter_context returns live state, not a cold snapshot, before the agent acts', - }); + // Gate hook next-steps on being inside a git repo + if (inGitRepo) { + result.nextSteps.push({ + cmd: 'charter hook install --pre-commit', + required: false, + reason: 'Install pre-commit hook for ADF evidence gate', + }); + result.nextSteps.push({ + cmd: 'charter hook install --commit-msg', + required: false, + reason: 'Install commit-msg hook for trailer enforcement', + }); + result.nextSteps.push({ + cmd: 'echo \'charter context --write\' >> .git/hooks/post-commit && chmod +x .git/hooks/post-commit', + required: false, + reason: 'Keep .charter/context.md fresh after each commit (charter brief auto-refresh)', + }); + } + + result.nextSteps.push({ + cmd: 'charter hook print --claude # paste output into .claude/settings.json → hooks.UserPromptSubmit', + required: false, + reason: 'Auto-refresh context at session start so charter_context returns live state, not a cold snapshot, before the agent acts', + }); + } // ======================================================================== // Governance Gaps — surface what's configured but not enforced diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 23a9276..bf2e908 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -39,8 +39,9 @@ charter - repo-level governance toolkit Usage: charter Show immediate governance value + risk snapshot - charter bootstrap [--ci github] [--preset ] [--yes] [--force] [--skip-install] [--skip-doctor] + charter bootstrap [--ci github] [--preset ] [--yes] [--force] [--skip-install] [--skip-doctor] [--mode lean] One-command repo onboarding (detect + setup + ADF + install + doctor) + --mode lean: run only detect, setup, adf-init, doctor; skip migrate/install/populate --security-sensitive adds SECURITY.md, hard security drift denies, and a security test check charter context [--stdout-only] [--verbose] [--write] Pre-digested repo brief for AI agents (routes, hotspots, governance) From d579f628dded72bbbe7a17f091acd1ddbd75efa3 Mon Sep 17 00:00:00 2001 From: Stackbilt Admin Date: Sat, 23 May 2026 07:21:49 -0500 Subject: [PATCH 2/4] feat(drift): scan template literal bodies for anti-patterns (#102) (#181) Extracts backtick template strings from .ts/.tsx/.js/.mjs files and scans their bodies against drift patterns, attributing violations to virtual filenames (e.g. src/foo.ts[template:0]). Catches security anti-patterns inside code-factory functions that emit string templates. No signature changes to scanForDrift. Co-authored-by: Kurt Overmier Co-authored-by: Claude Sonnet 4.6 --- .../src/__tests__/templateLiterals.test.ts | 156 ++++++++++++++++++ packages/drift/src/index.ts | 59 +++++++ 2 files changed, 215 insertions(+) create mode 100644 packages/drift/src/__tests__/templateLiterals.test.ts diff --git a/packages/drift/src/__tests__/templateLiterals.test.ts b/packages/drift/src/__tests__/templateLiterals.test.ts new file mode 100644 index 0000000..dc80850 --- /dev/null +++ b/packages/drift/src/__tests__/templateLiterals.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { extractTemplateLiterals, scanForDrift } from '../index'; +import type { Pattern } from '@stackbilt/types'; + +describe('extractTemplateLiterals', () => { + it('returns empty map for non-TS/JS files', () => { + const result = extractTemplateLiterals('const x = `hello world from template`;', 'config.yaml'); + expect(result).toEqual({}); + }); + + it('returns empty map for .txt files', () => { + const result = extractTemplateLiterals('`some long template content here`', 'readme.txt'); + expect(result).toEqual({}); + }); + + it('returns empty map when file has no template literals', () => { + const result = extractTemplateLiterals('const x = "hello"; const y = 42;', 'src/util.ts'); + expect(result).toEqual({}); + }); + + it('skips template literals with 20 chars or fewer', () => { + // 15 chars — should be skipped + const result = extractTemplateLiterals('const x = `short15chars`;', 'src/util.ts'); + expect(result).toEqual({}); + }); + + it('includes template literals longer than 20 chars', () => { + // 100+ chars — should be included + const body = 'this is a sufficiently long template literal body that exceeds the threshold'; + const result = extractTemplateLiterals(`const x = \`${body}\`;`, 'src/util.ts'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.values(result)[0]).toBe(body); + }); + + it('uses virtual filename format filename[template:N]', () => { + const body = 'this is a sufficiently long template literal body content'; + const result = extractTemplateLiterals(`const x = \`${body}\`;`, 'src/templates/hmac.ts'); + expect(Object.keys(result)[0]).toBe('src/templates/hmac.ts[template:0]'); + }); + + it('indexes multiple template literals sequentially', () => { + const body1 = 'first template literal body that is long enough to count'; + const body2 = 'second template literal body that is also long enough to count'; + const content = `const a = \`${body1}\`;\nconst b = \`${body2}\`;`; + const result = extractTemplateLiterals(content, 'src/util.ts'); + expect(Object.keys(result)).toContain('src/util.ts[template:0]'); + expect(Object.keys(result)).toContain('src/util.ts[template:1]'); + expect(result['src/util.ts[template:0]']).toBe(body1); + expect(result['src/util.ts[template:1]']).toBe(body2); + }); + + it('works for .js files', () => { + const body = 'function body that is long enough to be extracted by scanner'; + const result = extractTemplateLiterals(`module.exports = \`${body}\`;`, 'lib/factory.js'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('lib/factory.js[template:0]'); + }); + + it('works for .mjs files', () => { + const body = 'export default template that is long enough to be extracted by scanner'; + const result = extractTemplateLiterals(`export default \`${body}\`;`, 'lib/factory.mjs'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('lib/factory.mjs[template:0]'); + }); + + it('works for .tsx files', () => { + const body = 'react template literal body that is long enough to be extracted'; + const result = extractTemplateLiterals(`const style = \`${body}\`;`, 'src/Component.tsx'); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('src/Component.tsx[template:0]'); + }); +}); + +describe('scanForDrift with template literal extraction', () => { + const makePattern = (name: string, antiPatterns: string | null): Pattern => ({ + id: '1', + name, + category: 'SECURITY', + blessedSolution: 'Use constant-time comparison', + rationale: null, + antiPatterns, + documentationUrl: null, + relatedLedgerId: null, + status: 'ACTIVE', + createdAt: '2025-01-01', + projectId: null, + }); + + it('detects timing attack inside a template literal (code-factory pattern)', () => { + // A function that returns a multi-line backtick string containing the vulnerable code + const content = [ + 'export function hmacVerifyFunction(): string {', + ' return `', + 'function verify(a, b) {', + ' return a === b;', + '}', + '`;', + '}', + ].join('\n'); + + const pattern = makePattern('no-timing-attack', 'Avoid /a === b/'); + const files = { 'src/templates/hmac.ts': content }; + const report = scanForDrift(files, [pattern]); + + // Should find a violation attributed to the virtual template filename + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + expect(templateViolations[0].file).toMatch(/^src\/templates\/hmac\.ts\[template:\d+\]$/); + expect(templateViolations[0].snippet).toContain('a === b'); + }); + + it('attributes template violations to virtual filename, not original file', () => { + const content = 'export const tmpl = `\nreturn inputA === inputB;\n`;'; + const pattern = makePattern('no-direct-compare', 'Avoid /inputA === inputB/'); + const files = { 'src/codegen/auth.ts': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.startsWith('src/codegen/auth.ts[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + expect(templateViolations[0].file).toBe('src/codegen/auth.ts[template:0]'); + }); + + it('does not template-scan non-TS/JS files', () => { + // YAML file containing backtick-like content should not be template-scanned + const content = 'pattern: |\n return a === b;\n'; + const pattern = makePattern('no-timing-attack', 'Avoid /a === b/'); + const files = { 'config.yaml': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations).toHaveLength(0); + }); + + it('line numbers in template violations are 1-indexed relative to template body', () => { + // Template body: line 1 is "// begin generated code" (padding to exceed 20 chars), + // line 2 has the vulnerable pattern + const content = 'const gen = `\n// begin generated code\nreturn x === y;\n`;'; + const pattern = makePattern('no-direct-eq', 'Avoid /x === y/'); + const files = { 'src/gen.ts': content }; + const report = scanForDrift(files, [pattern]); + + const templateViolations = report.violations.filter(v => + v.file.includes('[template:') + ); + expect(templateViolations.length).toBeGreaterThan(0); + // line 1 is "// begin generated code" + // line 2 is "return x === y;" + expect(templateViolations[0].line).toBe(3); + }); +}); diff --git a/packages/drift/src/index.ts b/packages/drift/src/index.ts index 0c8faa2..c2de6f3 100644 --- a/packages/drift/src/index.ts +++ b/packages/drift/src/index.ts @@ -57,6 +57,32 @@ export function scanForDrift( } } } + + // Also scan extracted template literals as virtual files + const templateVirtualFiles = extractTemplateLiterals(content, filename); + for (const [vFilename, vContent] of Object.entries(templateVirtualFiles)) { + for (const pattern of patterns) { + if (pattern.antiPatterns) { + const rules = extractRules(pattern.antiPatterns); + + for (const rule of rules) { + const lines = vContent.split('\n'); + lines.forEach((line, index) => { + if (rule.test(line)) { + violations.push({ + file: vFilename, + line: index + 1, + snippet: line.trim().substring(0, 100), + patternName: pattern.name, + antiPattern: pattern.antiPatterns!, + severity: 'MAJOR' + }); + } + }); + } + } + } + } } const score = Math.max(0, 1.0 - (violations.length * 0.1)); @@ -70,6 +96,39 @@ export function scanForDrift( }; } +/** + * Extract template literal bodies from a source file. + * + * For .ts, .tsx, .js, and .mjs files, finds all backtick template literal + * bodies and returns them as a map of virtual filenames to extracted content. + * Virtual filenames have the format: `filename[template:N]` + * + * Handles nested backticks conservatively via non-greedy matching. + * Short template literals (≤20 chars) are skipped as trivially uninteresting. + * + * @param content - File content to extract from + * @param filename - Filename used to determine extension and virtual names + * @returns Map of virtual filename → extracted template body + */ +export function extractTemplateLiterals(content: string, filename: string): Record { + const supportedExtensions = ['.ts', '.tsx', '.js', '.mjs']; + const isSupported = supportedExtensions.some(ext => filename.endsWith(ext)); + if (!isSupported) return {}; + + const result: Record = {}; + // Match outermost backtick strings — simplified: find `...` blocks + const re = /`([\s\S]*?)`/g; + let match: RegExpExecArray | null; + let i = 0; + while ((match = re.exec(content)) !== null) { + const body = match[1]; + if (body.length > 20) { // skip trivially short template strings + result[`${filename}[template:${i++}]`] = body; + } + } + return result; +} + // ============================================================================ // Rule Extraction // ============================================================================ From cb120816201d7c735315ab832a12e101868cd9ae Mon Sep 17 00:00:00 2001 From: Stackbilt Admin Date: Sat, 23 May 2026 07:21:52 -0500 Subject: [PATCH 3/4] feat(context-refresh): add repo-intel source for GitHub history snapshots (#138) (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New --sources repo-intel pulls open/closed issues, PRs, and release cadence via gh CLI and writes .charter/repo-intel/snapshot.json. Computes a summary (openIssueCount, stalledIssues, recurringLabels, mergeVelocity, releaseCadence) contributed to context.adf openWork/recentActivity sections. Fails gracefully when gh is unavailable or repo has no GitHub remote — emits a warning, not an error. Co-authored-by: Kurt Overmier Co-authored-by: Claude Sonnet 4.6 --- docs/cli-reference.md | 15 +- .../context-refresh-repo-intel.test.ts | 256 +++++++++++++++++ packages/cli/src/commands/context-refresh.ts | 264 +++++++++++++++++- 3 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/__tests__/context-refresh-repo-intel.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 53c20c4..3bf54e2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -484,12 +484,13 @@ Seeds for hotspot analysis are chosen by resolved preset (from `.charter/config. Generates a live session snapshot and writes it to `.ai/context.adf` plus `.ai/context.snapshot.json`. -Phase 2 supports `git` and `github` sources with fail-closed behavior for missing GitHub credentials. +Supports `git`, `github`, and `repo-intel` sources with fail-closed behavior for missing GitHub credentials and graceful skip when the `gh` CLI is unavailable. ```bash npx charter context-refresh npx charter context-refresh --sources git npx charter context-refresh --sources git,github +npx charter context-refresh --sources repo-intel npx charter context-refresh --output CONTEXT.md npx charter context-refresh --ai-dir .ai npx charter context-refresh --once --ttl-minutes 30 @@ -498,7 +499,7 @@ npx charter context-refresh --format json #### Flags -- `--sources ` — context sources to include. Supported: `git`, `github`. +- `--sources ` — context sources to include. Supported: `git`, `github`, `repo-intel`. - `--output ` — optionally mirror a markdown snapshot to a file (for session briefs/docs). - `--ai-dir ` — target ADF directory (default: `.ai`), output file is `/context.adf`. - `--once` — skip refresh when an existing snapshot is newer than TTL. @@ -528,6 +529,16 @@ Optional config path: `.charter/context-sources.json` If `github` is enabled but `GITHUB_TOKEN` is missing, refresh continues without hard failure and records `sources.github.available = false` plus warnings. +If `repo-intel` is enabled but the `gh` CLI is not installed or has no GitHub remote, refresh continues without hard failure and records a warning. When available, `repo-intel` writes a full payload to `.charter/repo-intel/snapshot.json`. + +#### Sources reference + +| Source | Description | +|--------|-------------| +| `git` | Local git branch, working tree, and recent commit log. | +| `github` | Open issues from the GitHub API (requires `GITHUB_TOKEN`). | +| `repo-intel` | GitHub history via the `gh` CLI — open/closed issues, PRs, releases, and a computed summary (`openIssueCount`, `mergeVelocity`, `stalledIssues`, `recurringLabels`, `releaseCadence`). Writes `.charter/repo-intel/snapshot.json`. Skips gracefully when `gh` is unavailable. | + For active implementation status and next-session handoff details, see [Context Refresh Resume Guide](/context-refresh-resume). ### charter surface diff --git a/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts b/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts new file mode 100644 index 0000000..d5e8c5d --- /dev/null +++ b/packages/cli/src/__tests__/context-refresh-repo-intel.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for the repo-intel source in context-refresh. + * + * Uses vi.mock at the top level (required for ESM) to intercept execFileSync. + * A module-level `ghResponder` variable is mutated per-test so the hoisted + * mock factory can dispatch different responses without re-mocking. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CLIOptions } from '../index'; +import { contextRefreshCommand } from '../commands/context-refresh'; + +// --------------------------------------------------------------------------- +// Module-level gh responder — set this before each test, read by the mock +// --------------------------------------------------------------------------- +type GhResponder = ((args: string[]) => string) | null; +let ghResponder: GhResponder = null; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: vi.fn( + (cmd: string, args: unknown, opts: unknown): string => { + if (cmd === 'gh') { + if (!ghResponder) throw new Error('ENOENT: gh not found'); + return ghResponder(args as string[]); + } + // Pass through to real execFileSync for git and everything else + return actual.execFileSync( + cmd, + args as string[], + opts as Parameters[2], + ) as string; + }, + ), + }; +}); + +// --------------------------------------------------------------------------- +// Test scaffolding +// --------------------------------------------------------------------------- +const options: CLIOptions = { + configPath: '.charter', + format: 'text', + ciMode: false, + yes: false, +}; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-repo-intel-test-')); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + ghResponder = null; +}); + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + ghResponder = null; +}); + +// --------------------------------------------------------------------------- +// Fake data helpers +// --------------------------------------------------------------------------- +const fakeOpenIssues = [ + { + number: 1, + title: 'Fix bug in auth flow', + labels: [{ name: 'bug' }], + assignees: [], + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-10T00:00:00Z', + comments: 2, + }, + { + number: 2, + title: 'Improve onboarding docs', + labels: [{ name: 'docs' }], + assignees: [], + createdAt: '2026-03-01T00:00:00Z', + // Old updatedAt — should count as stalled (>30 days ago from 2026-05-23) + updatedAt: '2026-03-01T00:00:00Z', + comments: 0, + }, +]; + +const fakeClosedIssues = [ + { number: 3, title: 'Old bug 1', labels: [{ name: 'bug' }], closedAt: '2026-02-01T00:00:00Z' }, + { number: 4, title: 'Old bug 2', labels: [{ name: 'bug' }], closedAt: '2026-02-10T00:00:00Z' }, + { number: 5, title: 'Old bug 3', labels: [{ name: 'bug' }], closedAt: '2026-02-15T00:00:00Z' }, +]; + +const fakePRs = [ + { + number: 10, + title: 'feat: new feature', + state: 'MERGED', + author: { login: 'alice' }, + // Merged 5 days ago — should count toward mergeVelocity + mergedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: '2026-05-10T00:00:00Z', + reviewDecision: 'APPROVED', + labels: [], + }, +]; + +const fakeReleases = [ + // 9 days apart → releaseCadence should be 9 + { tagName: 'v1.0.0', publishedAt: '2026-05-01T00:00:00Z', isLatest: false }, + { tagName: 'v1.1.0', publishedAt: '2026-05-10T00:00:00Z', isLatest: true }, +]; + +function makeFullGhResponder(): GhResponder { + return (args: string[]) => { + if (args[0] === '--version') return 'gh version 2.0.0 (2026-01-01)'; + if (args[0] === 'issue') { + // args: ['issue', 'list', '--limit', '50', '--state', 'open', '--json', '...'] + const stateIdx = args.indexOf('--state'); + const state = stateIdx >= 0 ? args[stateIdx + 1] : undefined; + if (state === 'open') return JSON.stringify(fakeOpenIssues); + if (state === 'closed') return JSON.stringify(fakeClosedIssues); + return '[]'; + } + if (args[0] === 'pr') return JSON.stringify(fakePRs); + if (args[0] === 'release') return JSON.stringify(fakeReleases); + return '[]'; + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('context-refresh repo-intel source', () => { + it('writes .charter/repo-intel/snapshot.json and summary contains openIssueCount', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + ghResponder = makeFullGhResponder(); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + // Snapshot file must be written + const snapshotPath = path.join(tmp, '.charter', 'repo-intel', 'snapshot.json'); + expect(fs.existsSync(snapshotPath)).toBe(true); + + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as { + available: boolean; + summary: { + openIssueCount: number; + stalledIssues: number; + recurringLabels: string[]; + mergeVelocity: number; + releaseCadence: number | null; + }; + openIssues: unknown[]; + closedIssues: unknown[]; + pullRequests: unknown[]; + releases: unknown[]; + }; + + expect(snapshot.available).toBe(true); + expect(snapshot.summary.openIssueCount).toBe(2); + // Issue 2 was last updated 2026-03-01, which is >30 days before 2026-05-23 + expect(snapshot.summary.stalledIssues).toBeGreaterThanOrEqual(1); + // "bug" label appears 3 times in closed issues + expect(snapshot.summary.recurringLabels).toContain('bug'); + // PR merged 5 days ago is within 30-day window + expect(snapshot.summary.mergeVelocity).toBeGreaterThanOrEqual(1); + // Two releases 9 days apart → cadence of 9 + expect(snapshot.summary.releaseCadence).toBe(9); + // Raw arrays are present + expect(Array.isArray(snapshot.openIssues)).toBe(true); + expect(snapshot.openIssues).toHaveLength(2); + expect(Array.isArray(snapshot.closedIssues)).toBe(true); + expect(snapshot.closedIssues).toHaveLength(3); + expect(Array.isArray(snapshot.pullRequests)).toBe(true); + expect(Array.isArray(snapshot.releases)).toBe(true); + }); + + it('source appears in sourcesUsed and produces repo-intel entries in context.adf', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + ghResponder = makeFullGhResponder(); + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((v?: unknown) => { logs.push(String(v ?? '')); }); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + const payload = JSON.parse(logs[0]) as { sourcesUsed: string[]; warnings: string[] }; + expect(payload.sourcesUsed).toContain('repo-intel'); + expect(payload.warnings).toHaveLength(0); + + const adf = fs.readFileSync(path.join(tmp, '.ai', 'context.adf'), 'utf8'); + expect(adf).toContain('repo-intel'); + }); + + it('skips gracefully when gh CLI is not available — warning but no hard error', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + // Leave ghResponder = null → mock throws ENOENT for any gh call + ghResponder = null; + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((v?: unknown) => { logs.push(String(v ?? '')); }); + + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + ['--sources', 'repo-intel'], + ); + expect(exitCode).toBe(0); + + const payload = JSON.parse(logs[0]) as { + status: string; + sourcesUsed: string[]; + warnings: string[]; + errors: string[]; + }; + + // Graceful degradation: ok status, a warning, no errors + expect(payload.status).toBe('ok'); + expect(payload.sourcesUsed).not.toContain('repo-intel'); + expect(payload.warnings.some((w) => w.includes('repo-intel'))).toBe(true); + expect(payload.errors).toHaveLength(0); + + // Snapshot file must NOT be written when gh is unavailable + const snapshotPath = path.join(tmp, '.charter', 'repo-intel', 'snapshot.json'); + expect(fs.existsSync(snapshotPath)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/context-refresh.ts b/packages/cli/src/commands/context-refresh.ts index 67b1bed..e9ddc00 100644 --- a/packages/cli/src/commands/context-refresh.ts +++ b/packages/cli/src/commands/context-refresh.ts @@ -15,7 +15,7 @@ import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -type ContextSource = 'git' | 'github'; +type ContextSource = 'git' | 'github' | 'repo-intel'; interface GitCommit { hash: string; @@ -50,6 +50,60 @@ interface GitHubSnapshot { error?: string; } +// repo-intel types +interface RepoIntelIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + assignees: Array<{ login: string }>; + createdAt: string; + updatedAt: string; + comments: number; +} + +interface RepoIntelClosedIssue { + number: number; + title: string; + labels: Array<{ name: string }>; + closedAt: string; +} + +interface RepoIntelPR { + number: number; + title: string; + state: string; + author: { login: string }; + mergedAt: string | null; + createdAt: string; + reviewDecision: string | null; + labels: Array<{ name: string }>; +} + +interface RepoIntelRelease { + tagName: string; + publishedAt: string; + isLatest: boolean; +} + +interface RepoIntelSummary { + openIssueCount: number; + stalledIssues: number; + recurringLabels: string[]; + mergeVelocity: number; + releaseCadence: number | null; +} + +interface RepoIntelSnapshot { + available: boolean; + generatedAt: string; + openIssues: RepoIntelIssue[]; + closedIssues: RepoIntelClosedIssue[]; + pullRequests: RepoIntelPR[]; + releases: RepoIntelRelease[]; + summary: RepoIntelSummary; + error?: string; +} + interface DerivedItem { source: ContextSource; type: string; @@ -70,6 +124,7 @@ interface ContextSnapshot { sources: { git: GitSnapshot; github: GitHubSnapshot; + 'repo-intel': RepoIntelSnapshot; }; openWork: DerivedItem[]; recentActivity: DerivedItem[]; @@ -100,6 +155,9 @@ interface ContextConfig { includePullRequests: boolean; includeChecks: boolean; }; + 'repo-intel': { + enabled: boolean; + }; }; } @@ -117,7 +175,7 @@ interface ContextRefreshIO { log?: (message: string) => void; } -const SOURCE_SET = new Set(['git', 'github']); +const SOURCE_SET = new Set(['git', 'github', 'repo-intel']); const DEFAULT_CONFIG: ContextConfig = { version: 1, defaults: { @@ -140,6 +198,9 @@ const DEFAULT_CONFIG: ContextConfig = { includePullRequests: true, includeChecks: true, }, + 'repo-intel': { + enabled: true, + }, }, }; @@ -335,6 +396,11 @@ function loadContextConfig(configPath: string): ContextConfig { cfg.sources.github.includeChecks = github.includeChecks; } } + const repoIntelCfg = sources['repo-intel']; + if (repoIntelCfg && typeof repoIntelCfg === 'object') { + const ri = repoIntelCfg as Record; + if (typeof ri.enabled === 'boolean') cfg.sources['repo-intel'].enabled = ri.enabled; + } } return cfg; @@ -348,7 +414,7 @@ function parseRequestedSources(sourcesFlag: string | undefined, fallback: Contex .filter((entry) => entry.length > 0); const invalid = requested.filter((entry) => !SOURCE_SET.has(entry as ContextSource)); if (invalid.length > 0) { - throw new CLIError(`Unsupported --sources value(s): ${invalid.join(', ')}. Supported: git, github.`); + throw new CLIError(`Unsupported --sources value(s): ${invalid.join(', ')}. Supported: git, github, repo-intel.`); } return [...new Set(requested as ContextSource[])]; } @@ -410,7 +476,21 @@ async function buildSnapshot(cwd: string, resolved: RefreshOptionsResolved): Pro } } - const derived = deriveAggregates(git, github); + const repoIntelEnabled = resolved.sourcesRequested.includes('repo-intel') && resolved.config.sources['repo-intel'].enabled; + const repoIntel = repoIntelEnabled + ? collectRepoIntelSnapshot(cwd, generatedAt) + : { available: false, generatedAt, openIssues: [], closedIssues: [], pullRequests: [], releases: [], summary: { openIssueCount: 0, stalledIssues: 0, recurringLabels: [], mergeVelocity: 0, releaseCadence: null }, error: 'disabled' }; + if (repoIntel.available) { + sourcesUsed.push('repo-intel'); + // Persist full snapshot to .charter/repo-intel/snapshot.json + const repoIntelSnapshotPath = path.resolve(cwd, '.charter', 'repo-intel', 'snapshot.json'); + fs.mkdirSync(path.dirname(repoIntelSnapshotPath), { recursive: true }); + fs.writeFileSync(repoIntelSnapshotPath, JSON.stringify(repoIntel, null, 2), 'utf8'); + } else if (resolved.sourcesRequested.includes('repo-intel') && repoIntel.error && repoIntel.error !== 'disabled') { + warnings.push(`repo-intel source unavailable: ${repoIntel.error}`); + } + + const derived = deriveAggregates(git, github, repoIntel); return { version: 1, @@ -425,6 +505,7 @@ async function buildSnapshot(cwd: string, resolved: RefreshOptionsResolved): Pro sources: { git, github, + 'repo-intel': repoIntel, }, openWork: derived.openWork, recentActivity: derived.recentActivity, @@ -598,9 +679,154 @@ async function collectGitHubSnapshot(config: ContextConfig, issueLimit: number): }; } +function runGhCommand(args: string[], cwd?: string): string | null { + try { + const output = execFileSync('gh', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return output.trim(); + } catch { + return null; + } +} + +function collectRepoIntelSnapshot(cwd: string, generatedAt: string): RepoIntelSnapshot { + + const empty: RepoIntelSnapshot = { + available: false, + generatedAt, + openIssues: [], + closedIssues: [], + pullRequests: [], + releases: [], + summary: { openIssueCount: 0, stalledIssues: 0, recurringLabels: [], mergeVelocity: 0, releaseCadence: null }, + }; + + // Check if gh CLI is available + const ghVersion = runGhCommand(['--version'], cwd); + if (!ghVersion) { + return { ...empty, error: 'gh CLI not available' }; + } + + // Open issues (last 50, sorted by updated) + const openIssuesRaw = runGhCommand([ + 'issue', 'list', '--limit', '50', '--state', 'open', + '--json', 'number,title,labels,assignees,createdAt,updatedAt,comments', + ], cwd); + if (!openIssuesRaw) { + return { ...empty, error: 'no GitHub remote or gh auth required' }; + } + + let openIssues: RepoIntelIssue[]; + try { + openIssues = JSON.parse(openIssuesRaw) as RepoIntelIssue[]; + } catch { + return { ...empty, error: 'invalid_json: open issues response' }; + } + + // Recent closed issues (last 20) + const closedIssuesRaw = runGhCommand([ + 'issue', 'list', '--limit', '20', '--state', 'closed', + '--json', 'number,title,labels,closedAt', + ], cwd); + let closedIssues: RepoIntelClosedIssue[] = []; + if (closedIssuesRaw) { + try { + closedIssues = JSON.parse(closedIssuesRaw) as RepoIntelClosedIssue[]; + } catch { /* ignore parse failures for supplemental data */ } + } + + // Recent PRs (last 30, all states) + const prsRaw = runGhCommand([ + 'pr', 'list', '--limit', '30', '--state', 'all', + '--json', 'number,title,state,author,mergedAt,createdAt,reviewDecision,labels', + ], cwd); + let pullRequests: RepoIntelPR[] = []; + if (prsRaw) { + try { + pullRequests = JSON.parse(prsRaw) as RepoIntelPR[]; + } catch { /* ignore */ } + } + + // Release cadence (last 10 releases) + const releasesRaw = runGhCommand([ + 'release', 'list', '--limit', '10', + '--json', 'tagName,publishedAt,isLatest', + ], cwd); + let releases: RepoIntelRelease[] = []; + if (releasesRaw) { + try { + releases = JSON.parse(releasesRaw) as RepoIntelRelease[]; + } catch { /* ignore */ } + } + + // Compute summary + const now = Date.now(); + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + + const stalledIssues = openIssues.filter((issue) => { + const updatedMs = Date.parse(issue.updatedAt); + return Number.isFinite(updatedMs) && (now - updatedMs) > thirtyDaysMs; + }).length; + + // Count label occurrences in closed issues + const labelCounts = new Map(); + for (const issue of closedIssues) { + for (const label of issue.labels) { + const name = label.name; + labelCounts.set(name, (labelCounts.get(name) ?? 0) + 1); + } + } + const recurringLabels = [...labelCounts.entries()] + .filter(([, count]) => count >= 3) + .sort((a, b) => b[1] - a[1]) + .map(([name]) => name); + + const mergeVelocity = pullRequests.filter((pr) => { + if (!pr.mergedAt) return false; + const mergedMs = Date.parse(pr.mergedAt); + return Number.isFinite(mergedMs) && (now - mergedMs) <= thirtyDaysMs; + }).length; + + let releaseCadence: number | null = null; + const lastFiveReleases = releases + .slice(0, 5) + .map((r) => Date.parse(r.publishedAt)) + .filter((ms) => Number.isFinite(ms)) + .sort((a, b) => b - a); + if (lastFiveReleases.length >= 2) { + const gaps: number[] = []; + for (let i = 0; i < lastFiveReleases.length - 1; i++) { + gaps.push((lastFiveReleases[i]! - lastFiveReleases[i + 1]!) / (24 * 60 * 60 * 1000)); + } + releaseCadence = Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length); + } + + const summary: RepoIntelSummary = { + openIssueCount: openIssues.length, + stalledIssues, + recurringLabels, + mergeVelocity, + releaseCadence, + }; + + return { + available: true, + generatedAt, + openIssues, + closedIssues, + pullRequests, + releases, + summary, + }; +} + function deriveAggregates( git: GitSnapshot, github: GitHubSnapshot, + repoIntel: RepoIntelSnapshot, ): { openWork: DerivedItem[]; recentActivity: DerivedItem[]; @@ -663,6 +889,36 @@ function deriveAggregates( } } + if (repoIntel.available) { + const s = repoIntel.summary; + recentActivity.push({ + source: 'repo-intel', + type: 'summary', + summary: `repo-intel: ${s.openIssueCount} open issues, ${s.mergeVelocity} PRs merged in last 30d, ${s.stalledIssues} stalled`, + }); + if (s.stalledIssues > 0) { + openWork.push({ + source: 'repo-intel', + type: 'stalled-issues', + summary: `${s.stalledIssues} open issue(s) with no activity in 30+ days`, + }); + } + if (s.recurringLabels.length > 0) { + pendingDecisions.push({ + source: 'repo-intel', + type: 'recurring-labels', + summary: `Recurring closed-issue labels (≥3 times): ${s.recurringLabels.slice(0, 5).join(', ')}`, + }); + } + if (s.releaseCadence !== null) { + recentActivity.push({ + source: 'repo-intel', + type: 'release-cadence', + summary: `Avg release cadence: ~${s.releaseCadence} day(s) between last 5 releases`, + }); + } + } + return { openWork, recentActivity, pendingDecisions }; } From 0f8495fb9446341310393585a4fd9b9c91dfb978 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 07:29:27 -0500 Subject: [PATCH 4/4] fix(bootstrap): gate lean-mode hook next-steps on inGitRepo Hooks cannot be installed outside a git repo. The non-lean path already gated these steps on inGitRepo; lean mode was missing the same guard. Tests updated to mock isGitRepo via git-helpers module mock (runGit uses execFileSync, not execSync, so the existing execSync override couldn't cover it). Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/__tests__/bootstrap.test.ts | 27 ++++++++++++++++++-- packages/cli/src/commands/bootstrap.ts | 22 ++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index 00b3596..7697118 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -8,6 +8,13 @@ import { driftCommand } from '../commands/drift'; import type { CLIOptions } from '../index'; import { parseAdf, parseManifest } from '@stackbilt/adf'; +// Controlled per-test override for isGitRepo (git-helpers uses execFileSync, not execSync) +let mockIsGitRepo: boolean | null = null; +vi.mock('../git-helpers', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isGitRepo: () => mockIsGitRepo !== null ? mockIsGitRepo : actual.isGitRepo() }; +}); + // Controlled per-test override for execSync (module-level mock needed for ESM-treated builtins) let execSyncOverride: (((...args: unknown[]) => unknown) | null) = null; vi.mock('node:child_process', async (importOriginal) => { @@ -342,12 +349,13 @@ load state.adf always it('emits the detected package manager install command as the first required next step', async () => { fs.writeFileSync('pnpm-lock.yaml', ''); - + mockIsGitRepo = true; logs = []; await bootstrapCommand( { ...baseOptions, format: 'json' }, ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], ); + mockIsGitRepo = null; const report = JSON.parse(logs[0]); expect(report.nextSteps.length).toBe(4); @@ -356,12 +364,14 @@ load state.adf always expect(first.required).toBe(true); }); - it('emits exactly 4 deterministic next steps in the correct order', async () => { + it('emits exactly 4 deterministic next steps in the correct order (in a git repo)', async () => { + mockIsGitRepo = true; logs = []; await bootstrapCommand( { ...baseOptions, format: 'json' }, ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], ); + mockIsGitRepo = null; const report = JSON.parse(logs[0]); expect(report.nextSteps.length).toBe(4); @@ -371,6 +381,19 @@ load state.adf always expect(report.nextSteps[3].cmd).toBe('charter serve'); }); + it('emits only install + serve next steps outside a git repo', async () => { + logs = []; + await bootstrapCommand( + { ...baseOptions, format: 'json' }, + ['--preset', 'worker', '--mode', 'lean', '--skip-doctor'], + ); + + const report = JSON.parse(logs[0]); + expect(report.nextSteps.length).toBe(2); + expect(report.nextSteps[0].cmd).toMatch(/install$/); + expect(report.nextSteps[1].cmd).toBe('charter serve'); + }); + it('--mode lean combined with --skip-install is not an error', async () => { logs = []; const exitCode = await bootstrapCommand( diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 5fa6185..1f48815 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -342,16 +342,18 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro required: true, reason: 'Install dependencies (skipped in lean mode)', }); - result.nextSteps.push({ - cmd: 'charter hook install --commit-msg', - required: false, - reason: 'Install commit-msg hook for trailer enforcement', - }); - result.nextSteps.push({ - cmd: 'charter hook install --pre-commit', - required: false, - reason: 'Install pre-commit hook for ADF evidence gate', - }); + if (inGitRepo) { + result.nextSteps.push({ + cmd: 'charter hook install --commit-msg', + required: false, + reason: 'Install commit-msg hook for trailer enforcement', + }); + result.nextSteps.push({ + cmd: 'charter hook install --pre-commit', + required: false, + reason: 'Install pre-commit hook for ADF evidence gate', + }); + } result.nextSteps.push({ cmd: 'charter serve', required: false,