diff --git a/.husky/pre-commit b/.husky/pre-commit index 10c643c2..8510e2e9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -33,6 +33,31 @@ if [ -z "${DEVKIT_SHIP_ID:-}" ] && [ -z "${DEVKIT_NO_TELEMETRY:-}" ]; then fi # /devkit:commit-terminal +__dk_gate_selected() { + [ "${DEVKIT_RUN_MODE:-}" != "review" ] && return 0 + __dk_review_guards=$(printf '%s' "${DEVKIT_REVIEW_GUARDS:-}" | sed -e 's/[[:space:]]*,[[:space:]]*/,/g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + case ",${__dk_review_guards}," in + *,"$1",*) return 0 ;; + *) return 1 ;; + esac +} + +__dk_review_baseline_gate() { + [ -n "${DEVKIT_REVIEW_BASELINE_DIR:-}" ] || { + echo "devkit review: merge-base baseline runtime is missing β€” reinstall/rebuild Devkit." >&2 + return 1 + } + __dk_baseline_gate= + for __dk_candidate in "${DEVKIT_REVIEW_PACKAGE_ROOT:-}/gate-engine/review/baseline-gate.mjs" "${DEVKIT_REVIEW_PACKAGE_ROOT:-}/gate-engine/review/baseline-gate.mts"; do + if [ -f "$__dk_candidate" ]; then __dk_baseline_gate=$__dk_candidate; break; fi + done + [ -n "$__dk_baseline_gate" ] || { + echo "devkit review: baseline helper is missing β€” reinstall/rebuild Devkit." >&2 + return 1 + } + node "$__dk_baseline_gate" "$1" "$DEVKIT_REVIEW_BASELINE_DIR" +} + # devkit:biome-format # Format staged files with biome, then re-stage exactly those (scoped β€” never a blanket # `git add -u`, which would sweep unrelated working-tree changes into the commit). Only @@ -56,6 +81,7 @@ echo "🚧 Deterministic gates (aggregated)..." node gate-engine/deterministic/run.mts --hook "${DK_HOOK_PATH:-$0}" --structure "bun run lint:structure" --extra "lint=bun run lint" --extra "benchmarks=bun run benchmarks:check -- --mode staged" || exit 1 # /devkit:deterministic +if __dk_gate_selected decisions; then # devkit:guard-decisions echo "🧭 Decision-log gate..." ddrc=0 @@ -73,7 +99,9 @@ elif [ "$ddrc" -ne 0 ] && [ "$ddrc" -ne 2 ]; then fi # ddrc 0 = clean / staged / routine / bypassed, ddrc 2 = fail-open β†’ continue; any other code blocks. # /devkit:guard-decisions +fi +if __dk_gate_selected review; then # devkit:guard-review echo "πŸ” Reviewer gate (headless domain judges)..." rrc=0 @@ -91,7 +119,9 @@ elif [ "$rrc" -ne 0 ] && [ "$rrc" -ne 2 ]; then fi # rrc 0 = pass/cached/nothing-to-do, rrc 2 = inconclusive (non-strict fail-open) β†’ continue. # /devkit:guard-review +fi +if __dk_gate_selected qavis-advisory; then # devkit:guard-qavis-advisory qarc=0 node gate-engine/qavis-advisory/cli.mts --gate || qarc=$? @@ -99,17 +129,18 @@ node gate-engine/qavis-advisory/cli.mts --gate || qarc=$? # qarc 0 = continue (SILENT / advisory-only / receipt-cleared / qavis absent); 3 = strict-ship block # (the remedy β€” run qavis, or export GUARD_QAVIS_OK=1 β€” is printed by the bin). # /devkit:guard-qavis-advisory +fi # devkit:fallow-advisory # fallow audit β€” dead-code / duplication / complexity on the changed set; advisory, never blocks. -# DEVKIT_SHIP_BASE_SHA (exported by devkit ship β€” see commit-with-gate-capture.sh) pins the audit's -# comparison ref to the exact commit the ship worktree was cut from, instead of fallow's own -# main-autodetect: a --base ship off a long-lived/stacked branch would otherwise misreport that -# branch's own pre-existing findings vs main as "new" (DK-5). Unset on a plain `git commit` β€” fallow -# falls back to its own default. -FALLOW_BASE_ARGS="" -[ -n "${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA" -command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true +if [ "${DEVKIT_RUN_MODE:-}" = "review" ]; then + __dk_review_baseline_gate fallow || true +else + # Pin ships to their exact worktree base (DK-5); plain commits retain Fallow's base discovery. + FALLOW_BASE_ARGS="" + [ -n "${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA" + command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true +fi # /devkit:fallow-advisory # <<< devkit-guards <<< diff --git a/cli/__tests__/apply-init.test.mts b/cli/__tests__/apply-init.test.mts index 686f9c4f..6267098c 100644 --- a/cli/__tests__/apply-init.test.mts +++ b/cli/__tests__/apply-init.test.mts @@ -102,6 +102,23 @@ describe('selection helpers', () => { ]); expect(parseFlags(['--scan-roots', 'a/src, b/src']).scanRoots).toEqual(['a/src', 'b/src']); }); + + it('parseFlags reads the local review profile flags', () => { + expect( + parseFlags([ + '--review', + '--review-guards', + 'size,decisions', + '--review-decisions-dir', + 'architecture/decisions', + ]), + ).toMatchObject({ + review: true, + reviewGuards: ['size', 'decisions'], + reviewDecisionsDir: 'architecture/decisions', + }); + expect(parseFlags(['--no-review']).review).toBe(false); + }); }); describe('applyInit (direct chosen map β€” the wizard seam)', () => { @@ -125,6 +142,97 @@ describe('applyInit (direct chosen map β€” the wizard seam)', () => { expect(existsSync(join(root, '.husky/pre-commit'))).toBe(true); const cfg = config(root); expect(cfg.components).toMatchObject({ biome: false, skills: false, guards: ['size'] }); + expect(cfg.review).toEqual({ + enabled: false, + guards: ['size'], + decisionsDir: 'docs/decisions', + }); + }); + + it('writes and preserves a local review profile, pruning guards removed from the install', async () => { + const root = tmpRepo(); + const selection = { + biome: false, + tsconfig: false, + skills: false, + husky: true, + structure: false, + guards: ['size', 'decisions'], + }; + await applyInit(root, { + stack: 'generic', + selection, + review: { + enabled: true, + guards: ['decisions'], + decisionsDir: 'architecture/decisions', + }, + }); + expect(config(root).review).toEqual({ + enabled: true, + guards: ['decisions'], + decisionsDir: 'architecture/decisions', + }); + + await applyInit(root, { + stack: 'generic', + selection: { ...selection, guards: ['size'] }, + }); + expect(config(root).review).toEqual({ + enabled: true, + guards: [], + decisionsDir: 'architecture/decisions', + }); + }); + + it('migrates a legacy initialized config without a review section as enabled', async () => { + const root = tmpRepo(); + const selection = { + biome: false, + tsconfig: false, + skills: false, + husky: true, + structure: false, + guards: ['size'], + }; + await applyInit(root, { stack: 'generic', selection }); + const cfgPath = join(root, '.devkit/config.json'); + const legacy = config(root); + delete legacy.review; + writeFileSync(cfgPath, `${JSON.stringify(legacy, null, 2)}\n`); + + await applyInit(root, { stack: 'generic', selection }); + + expect(config(root).review).toEqual({ + enabled: true, + guards: ['size'], + decisionsDir: 'docs/decisions', + }); + }); + + it('preserves legacy-enabled review behavior when reapplying an overlay', async () => { + const root = tmpRepo(); + const selection = { + biome: false, + tsconfig: false, + skills: false, + husky: true, + structure: false, + guards: ['size'], + }; + await applyInit(root, { stack: 'generic', selection, overlay: true }); + const cfgPath = join(root, '.devkit/config.json'); + const legacy = config(root); + delete legacy.review; + writeFileSync(cfgPath, `${JSON.stringify(legacy, null, 2)}\n`); + + await applyInit(root, { stack: 'generic', selection, overlay: true }); + + expect(config(root).review).toEqual({ + enabled: true, + guards: ['size'], + decisionsDir: 'docs/decisions', + }); }); it('removes a deselected-but-present component when listed in `remove`', async () => { diff --git a/cli/__tests__/husky-block-exec.test.mts b/cli/__tests__/husky-block-exec.test.mts index 361f76d1..d800b203 100644 --- a/cli/__tests__/husky-block-exec.test.mts +++ b/cli/__tests__/husky-block-exec.test.mts @@ -96,6 +96,24 @@ describe('assembled hook execution (stubbed bunx, sh -e)', () => { expect(r.calls).toContain('guard-review'); }); + it('review mode runs only AI gates in the explicit review allowlist', () => { + const r = runHook({ DEVKIT_RUN_MODE: 'review', DEVKIT_REVIEW_GUARDS: 'decisions' }); + expect(r.status).toBe(0); + expect(r.calls).toContain('guard-deterministic'); + expect(r.calls).toContain('guard-decisions'); + expect(r.calls).not.toContain('guard-review'); + }); + + it('trims review allowlist entries consistently with the deterministic parser', () => { + const r = runHook({ + DEVKIT_RUN_MODE: 'review', + DEVKIT_REVIEW_GUARDS: ' decisions , review ', + }); + expect(r.status).toBe(0); + expect(r.calls).toContain('guard-decisions'); + expect(r.calls).toContain('guard-review'); + }); + it('passes the resolved structure command through to the orchestrator', () => { const r = runHook( { DET_RC: '0' }, diff --git a/cli/__tests__/husky-block.test.mts b/cli/__tests__/husky-block.test.mts index eff02c5b..8f49d2a7 100644 --- a/cli/__tests__/husky-block.test.mts +++ b/cli/__tests__/husky-block.test.mts @@ -63,6 +63,9 @@ describe('buildGuardBlock', () => { // AI guards keep their own fail-fast fragments. expect(block).toContain('bunx guard-decisions'); expect(block).toContain('bunx guard-review'); + expect(block).toContain('__dk_gate_selected decisions'); + expect(block).toContain('__dk_gate_selected review'); + expect(block).toContain('DEVKIT_REVIEW_GUARDS'); }); it('omits the biome step when biome is deselected', () => { @@ -325,6 +328,17 @@ describe('buildOverlayHook β€” gates-only guard for the global init.sh shim', () ); expect(withStruct).not.toContain('--structure'); }); + + it('uses merge-base ESLint/Fallow baselines only in review mode and preserves commit behavior', () => { + const withFallow = buildOverlayHook({ guards: [...GUARD_IDS] }, '.husky/pre-commit', '', { + fallow: true, + }); + expect(withFallow).toContain('__dk_review_baseline_gate eslint'); + expect(withFallow).toContain('__dk_review_baseline_gate fallow'); + expect(withFallow).toContain('node_modules/.bin/eslint -c eslint.config.devkit.mjs'); + expect(withFallow).toContain('command -v fallow'); + expect(withFallow).toContain('baseline-gate.mjs'); + }); }); // DK-5: overlay's fallow gate BLOCKS on new findings (unlike the self-host advisory twin), and it @@ -349,7 +363,7 @@ describe('buildOverlayHook β€” fallow gate (overlay)', () => { it('passes the ship base through to a stubbed fallow (no real binary needed)', () => { const fragment = hook.match( - /# devkit fallow gate \(overlay\)[\s\S]*?fallow audit \$FALLOW_BASE_ARGS \|\| exit 1; \}/, + /# devkit fallow gate \(overlay\)[\s\S]*?fallow audit \$FALLOW_BASE_ARGS \|\| exit 1; \}\nfi/, )?.[0]; expect(fragment).toBeDefined(); const script = `fallow() { echo "FALLOW_ARGS:$*"; }\n${fragment}`; diff --git a/cli/__tests__/init-doctor.test.mts b/cli/__tests__/init-doctor.test.mts index 0ce3de79..7f1ea4dc 100644 --- a/cli/__tests__/init-doctor.test.mts +++ b/cli/__tests__/init-doctor.test.mts @@ -10,6 +10,159 @@ const { tmpRepo, devkit, cleanup } = tmpRepos('init-'); afterEach(cleanup); describe('init --yes (all recommended)', () => { + it('persists an explicit review profile from CLI flags', () => { + const root = tmpRepo(); + const r = devkit( + root, + 'init', + '--yes', + '--guards', + 'size,decisions,review', + '--review', + '--review-guards', + 'decisions,review', + '--review-decisions-dir', + 'architecture/decisions', + ); + + expect(r.status, r.stderr).toBe(0); + expect(config(root).review).toEqual({ + enabled: true, + guards: ['decisions', 'review'], + decisionsDir: 'architecture/decisions', + }); + }); + + it('accepts an explicitly empty review guard allowlist', () => { + const root = tmpRepo(); + const result = devkit(root, 'init', '--yes', '--review', '--review-guards', ''); + + expect(result.status, result.stderr).toBe(0); + expect(config(root).review).toEqual({ + enabled: true, + guards: [], + decisionsDir: 'docs/decisions', + }); + }); + + it('requires explicit --review before accepting review-profile modifiers', () => { + const guardsOnly = tmpRepo(); + const guardsResult = devkit(guardsOnly, 'init', '--yes', '--review-guards', 'size'); + expect(guardsResult.status).toBe(1); + expect(guardsResult.stderr).toMatch(/--review-guards require --review/); + + const directoryOnly = tmpRepo(); + const directoryResult = devkit( + directoryOnly, + 'init', + '--yes', + '--review-decisions-dir', + 'architecture/decisions', + ); + expect(directoryResult.status).toBe(1); + expect(directoryResult.stderr).toMatch(/--review-decisions-dir require --review/); + + const disabledWithModifier = tmpRepo(); + const disabledResult = devkit( + disabledWithModifier, + 'init', + '--yes', + '--no-review', + '--review-guards', + 'size', + ); + expect(disabledResult.status).toBe(1); + expect(disabledResult.stderr).toMatch(/--review-guards require --review/); + }); + + it('rejects typoed and uninstalled review guard selections', () => { + const typo = tmpRepo(); + const typoResult = devkit(typo, 'init', '--yes', '--review', '--review-guards', 'decision'); + expect(typoResult.status).toBe(1); + expect(typoResult.stderr).toMatch(/invalid --review-guards.*unknown: decision/); + + const uninstalled = tmpRepo(); + const uninstalledResult = devkit( + uninstalled, + 'init', + '--yes', + '--review', + '--review-guards', + 'review', + ); + expect(uninstalledResult.status).toBe(1); + expect(uninstalledResult.stderr).toMatch(/not selected by --guards: review/); + + const noHook = tmpRepo(); + const noHookResult = devkit( + noHook, + 'init', + '--yes', + '--no-husky', + '--review', + '--review-guards', + 'size', + ); + expect(noHookResult.status).toBe(1); + expect(noHookResult.stderr).toMatch(/--review requires the husky pre-commit component/); + }); + + it('validates review flags against overlay-effective components', () => { + const root = tmpRepo(); + expect(spawnSync('git', ['init', '-q'], { cwd: root }).status).toBe(0); + const result = devkit( + root, + 'init', + '--overlay', + '--yes', + '--no-husky', + '--review', + '--review-guards', + 'size', + ); + + expect(result.status, result.stderr).toBe(0); + const recorded = config(root); + expect(recorded.overlay).toBe(true); + expect(existsSync(join(root, '.devkit', 'hooks', 'pre-commit'))).toBe(true); + expect(recorded.review).toEqual({ + enabled: true, + guards: ['size'], + decisionsDir: 'docs/decisions', + }); + }); + + it('keeps the virtual review profile out of physical component removals', () => { + const root = tmpRepo(); + expect(devkit(root, 'init', '--yes', '--review', '--review-guards', 'size').status).toBe(0); + + const result = devkit( + root, + 'init', + '--yes', + '--remove-deselected', + '--review', + '--review-guards', + 'size', + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).not.toMatch(/Removing deselected component\(s\):.*devkit-review/); + expect(config(root).review.enabled).toBe(true); + }); + + it('disables a persisted review profile when the effective hook is removed', () => { + const root = tmpRepo(); + expect(devkit(root, 'init', '--yes', '--review', '--review-guards', 'size').status).toBe(0); + + const result = devkit(root, 'init', '--yes', '--no-husky', '--remove-deselected'); + + expect(result.status, result.stderr).toBe(0); + expect(config(root).components.husky).toBe(false); + expect(config(root).review.enabled).toBe(false); + expect(readFileSync(join(root, '.husky', 'pre-commit'), 'utf8')).not.toContain('guard-review'); + }); + it('emits the full generic config set + husky hook + .devkit/config.json', () => { const root = tmpRepo(); const r = devkit(root, 'init', '--stack', 'generic', '--yes'); diff --git a/cli/__tests__/self-host.test.mts b/cli/__tests__/self-host.test.mts index 42ff7e78..021b0446 100644 --- a/cli/__tests__/self-host.test.mts +++ b/cli/__tests__/self-host.test.mts @@ -84,6 +84,7 @@ describe('buildSelfHostHook', () => { // replaceGuardBlock preserves it on a re-run and the parity/doctor check covers it. expect(hook.indexOf('fallow audit')).toBeGreaterThan(hook.indexOf('>>> devkit-guards')); expect(hook.indexOf('fallow audit')).toBeLessThan(hook.indexOf('<<< devkit-guards')); + expect(hook).toContain('__dk_review_baseline_gate fallow || true'); expect(hook.trimEnd().endsWith('exit 0')).toBe(true); }); diff --git a/cli/__tests__/wizard.test.mts b/cli/__tests__/wizard.test.mts index 0b546d93..6a488086 100644 --- a/cli/__tests__/wizard.test.mts +++ b/cli/__tests__/wizard.test.mts @@ -39,6 +39,7 @@ function setAnswers(surface) { 'Select components to install': ['skills', 'agents'], [SURFACE_Q]: surface, 'Select gate guards': [], + 'Enable devkit review?': false, 'Apply?': true, }); } @@ -71,4 +72,77 @@ describe('wizard agent-surface selection', () => { expect(existsSync(join(root, '.claude'))).toBe(true); expect(existsSync(join(root, '.cursor'))).toBe(false); }); + + it('records an explicit local review guard profile when enabled', async () => { + setAnswers('claude'); + Object.assign(answers, { + 'Select components to install': ['skills', 'agents', 'husky'], + 'Select gate guards': ['size', 'decisions'], + 'Enable devkit review?': true, + 'Select guards for devkit review': ['decisions'], + }); + + const r = await runWizard(WIZ_OPTS); + + expect(r.review).toEqual({ + enabled: true, + guards: ['decisions'], + decisionsDir: 'docs/decisions', + }); + }); + + it('keeps an enabled review profile empty when no installed guard is available', async () => { + setAnswers('claude'); + Object.assign(answers, { + 'Select components to install': ['skills', 'agents', 'husky'], + 'Select gate guards': [], + 'Enable devkit review?': true, + // If the wizard incorrectly opens an optionless picker, this impossible answer leaks through. + 'Select guards for devkit review': ['size'], + }); + + const r = await runWizard(WIZ_OPTS); + + expect(r.review).toEqual({ + enabled: true, + guards: [], + decisionsDir: 'docs/decisions', + }); + }); + + it('preserves a custom decisions directory that the wizard does not expose', async () => { + setAnswers('claude'); + Object.assign(answers, { + 'Select components to install': ['skills', 'agents', 'husky'], + 'Select gate guards': ['decisions'], + 'Enable devkit review?': true, + 'Select guards for devkit review': ['decisions'], + }); + + const r = await runWizard({ + ...WIZ_OPTS, + installed: new Set(['devkit-review']), + existingReview: { + enabled: true, + guards: ['decisions'], + decisionsDir: 'architecture/decisions', + }, + }); + + expect(r.review.decisionsDir).toBe('architecture/decisions'); + }); + + it('keeps review disabled when the effective selection has no Husky hook', async () => { + setAnswers('claude'); + answers['Enable devkit review?'] = true; + + const r = await runWizard(WIZ_OPTS); + + expect(r.selection.husky).toBe(false); + expect(r.review).toEqual({ + enabled: false, + guards: [], + decisionsDir: 'docs/decisions', + }); + }); }); diff --git a/cli/commands/doctor.mts b/cli/commands/doctor.mts index d8eb05f7..63330014 100644 --- a/cli/commands/doctor.mts +++ b/cli/commands/doctor.mts @@ -10,7 +10,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { RECOMMENDED_GUARD_IDS, type Selection } from '../lib/components.mts'; +import { RECOMMENDED_GUARD_IDS, type Selection, structureCmdFor } from '../lib/components.mts'; import { detectGitRoot } from '../lib/detect-git-root.mts'; import { packageDir, readJson, sha256 } from '../lib/fs-helpers.mts'; import { checkCommitMsgHook, commitMsgGuards } from '../lib/husky/commit-msg-block.mts'; @@ -27,7 +27,6 @@ import { checkHookRegistrations } from '../lib/install/install-hooks.mts'; import { HEAL_ALIAS_NAME, isHealAlias, syncOverlayHook } from '../lib/overlay.mts'; import { globalHookInstalled, globalInitPath } from '../lib/overlay-global-hook.mts'; import { bundledNames } from '../lib/sync-manifest.mts'; -import { structureCmdFor } from './init.mts'; import { cmpSemver } from './update.mts'; // A devkit dep ref counts as "pinned" when it ends in a #v tag. diff --git a/cli/commands/init.mts b/cli/commands/init.mts index cd7d1d98..d39325dc 100644 --- a/cli/commands/init.mts +++ b/cli/commands/init.mts @@ -30,15 +30,20 @@ import { AGENT_TARGETS, applyOverlayConstraints, COMPONENTS, + CONFIG_DRIVEN_STRUCTURE, defaultSelection, GUARD_IDS, + normalizeReviewProfile, + type ReviewProfile, type Selection, + structureCmdFor, } from '../lib/components.mts'; import { detectGitRoot } from '../lib/detect-git-root.mts'; import { detectStack } from '../lib/detect-stack.mts'; import { packageDir, readJson, writeIfAbsent } from '../lib/fs-helpers.mts'; import { generateImportWallBaseline } from '../lib/generate/generate-import-wall-baseline.mts'; import { generateStructureBaselines } from '../lib/generate/generate-structure-baseline.mts'; +import { INIT_HELP } from '../lib/help/init-help.mts'; import { installCommitMsgHook, removeCommitMsgBlock } from '../lib/husky/commit-msg-block.mts'; import { buildFullHook, @@ -65,6 +70,11 @@ import { syncHookScripts, } from '../lib/install/install-hooks.mts'; import { installSearchCode } from '../lib/install/install-search-code.mts'; +import { + parseReviewFlags, + type ReviewFlagValues, + reviewPlanFromFlags, +} from '../lib/install/review-profile.mts'; import { installOverlay } from '../lib/overlay.mts'; import { installGlobalHook } from '../lib/overlay-global-hook.mts'; import { installStandaloneConfigs, installStandaloneHook } from '../lib/standalone.mts'; @@ -86,17 +96,6 @@ const STRUCTURE_STACKS = new Set(['electron', 'react-app', 'component-lib']); // eslint/plugin dep). These get structure-lint even in standalone mode. Electron is EXCLUDED: its // preset imports the plugin + @typescript-eslint/parser directly in a consumer eslint.config.mjs and // uses eslint/domains.mjs, so it stays package-mode with consumer-side deps. -const CONFIG_DRIVEN_STRUCTURE = new Set(['react-app', 'component-lib']); - -// The structure-lint command a stack runs on the guard-deterministic `--structure` arg. Config-driven -// stacks use devkit's own `guard-structure` bin (the orchestrator resolves it as a sibling module, so -// no consumer eslint dep); every other structure stack (electron) keeps its consumer-side `bunx eslint -// src`. Shared with doctor's checkStructureLint so the expected arg stays in lockstep with what init -// emits when the stack rules change. -export function structureCmdFor(stack: string): string { - return CONFIG_DRIVEN_STRUCTURE.has(stack) ? 'guard-structure gate' : 'bunx eslint src'; -} - // The structure files each stack emits, [src-relative-to-template, dest-relative-to-cwd]. // The full install set adds biome/tsconfig/guard.config on top (installStructureFiles). const STRUCTURE_TEMPLATE_FILES: Record = { @@ -159,6 +158,7 @@ interface DevkitConfig { minDevkit?: string; configOverrides?: Record; components?: RecordedComponents; + review?: Partial; } // The consumer's package.json β€” only the maps init adds/removes entries in. @@ -204,13 +204,14 @@ interface InitPlan { globalCommitGate?: boolean; devkitRef?: string; regenStructureBaselines?: boolean; + review?: Partial; } // A component selection extended with the resolved structure-lint command β€” the shape the husky // block builders (buildFullHook / buildGuardBlock / installStandaloneHook) consume. type HookSelectionInput = Selection & { structureCmd?: string }; -interface InitFlags { +interface InitFlags extends ReviewFlagValues { yes: boolean; dryRun: boolean; force: boolean; @@ -247,6 +248,7 @@ function parseFlags(args: string[]): InitFlags { no: new Set(), guards: null, scanRoots: null, + ...parseReviewFlags(args), }; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -304,6 +306,7 @@ function detectInstalled(cwd: string) { const cfg = readJson(join(cwd, '.devkit', 'config.json')) as DevkitConfig | null; const installed = new Set(); const recorded = cfg?.components; + if (cfg?.review?.enabled) installed.add('devkit-review'); if (recorded) { for (const id of [ 'biome', @@ -965,9 +968,12 @@ function applyOverlay(cwd: string, plan: InitPlan, pkgRel: string, devkitRef: st console.log(' freeze baselines (grandfather current tree)'); runFreezes(cwd, dryRun); } - // Opt-in: close the plain-`git commit` gap (husky reclaims core.hooksPath) with a machine-global - // husky init.sh shim. One file, guarded no-op outside overlaid repos, removed by `devkit clean --global`. + // Optional machine-global shim closes the plain-commit gap; `devkit clean --global` removes it. const globalCommitGate = Boolean(plan.globalCommitGate); + const prevConfig = readJson(join(cwd, '.devkit', 'config.json')) as DevkitConfig | null; + const review = normalizeReviewProfile(plan.review ?? prevConfig?.review, selection.guards ?? [], { + enabledDefault: prevConfig !== null, + }); if (globalCommitGate) { console.log( ' global pre-commit gate (opt-in β€” survives husky reclaim on a plain `git commit`)', @@ -998,6 +1004,7 @@ function applyOverlay(cwd: string, plan: InitPlan, pkgRel: string, devkitRef: st fallow: fallowWired, agentTargets: [...(selection.agentTargets ?? AGENT_TARGETS)], }, + review, }, null, 2, @@ -1300,6 +1307,10 @@ export async function applyInit(cwd: string, plan: InitPlan) { // source-mode hook instead of the `bunx guard-*` one. selfHost, components, + review: normalizeReviewProfile(plan.review ?? prevConfig?.review, components.guards, { + enabledDefault: prevConfig !== null, + available: selection.husky, + }), }; const configPath = join(cwd, '.devkit', 'config.json'); if (dryRun) { @@ -1337,34 +1348,7 @@ function structureAvailableFor(stack: string) { export const meta = { name: 'init', summary: 'Wire this repo onto devkit (interactive wizard; idempotent).', - help: `devkit init β€” wire this repo onto @norvalbv/devkit (interactive on a TTY, idempotent). - -Usage: - devkit init [options] - - --stack electron | react-app | next | node-service | generic - (default: auto-detect; structure preset ships for electron + react-app). - --yes Non-interactive: install all recommended defaults (no prompts). - --dry-run Print every file action; write nothing. - --force Overwrite existing devkit-managed files, AND adopt/overwrite a consumer's - own same-named skill/agent/hook collisions (default: preserve them). - --no- Skip a component: --no-biome --no-tsconfig --no-skills --no-husky - --no-structure --no-guards --no-fallow. - --guards Only these guards (subset of size,fanout,dup,clone,decisions, - qavis-advisory,review,sentry; review + sentry are opt-in, off by default). - --no-claude/--no-cursor Sync skills/agents/hooks to ONE agent surface only (default both). - --baselines-only Re-derive ONLY the structure + import-wall baselines (rare; after a - structure-RULE change). Package-mode structure stacks only. - --fallow Also install the optional fallow code-health layer (off by default). - --search-code Opt this repo in to the semantic search index (off by default). - --standalone NO-PACKAGE mode: vendor configs + a fail-open hook calling GLOBAL guard-* - bins; add nothing to package.json. Requires \`bun add -g\` devkit. - --overlay LOCAL-ONLY mode for a repo you can't modify: git-ignored, chains to the - repo's own hook, configs EXTEND the repo's. Requires global devkit. - --scan-root Override guard.config.json scanRoots up front (set BEFORE the freezes). - --remove-deselected With --yes: remove an installed-but-now-deselected component (opt-in). - -See docs/glossary.md for package/standalone/overlay, gates, ratchets, baselines, scanRoot.`, + help: INIT_HELP, }; // Reason: flat CLI dispatch: resolves one `selection` via three converging paths (interactive wizard / --yes flags / non-TTY) then hands a fully-resolved plan to applyInit; the branches ARE the resolution-mode fork, each path linear with no shared nesting @@ -1380,11 +1364,9 @@ export default async function run(args: string[], cwd: string) { let selection: Selection; let remove: string[] = []; let mode = detectedMode; + let review: Partial | undefined; - // --baselines-only: re-derive the structure + import-wall baselines and NOTHING else (no config - // emit, package.json, husky, freezes, skills/agents, .devkit/config.json). For the RARE legit - // regen β€” a structure-RULE change (the baseline is otherwise generate-once + shrink-only). Guarded - // to the package-mode structure stacks: overlay/standalone omit structure; a bare repo has no preset. + // --baselines-only re-derives structure/import-wall baselines only for package-mode presets. if (flags.baselinesOnly) { if (mode !== 'package') { console.error( @@ -1407,10 +1389,7 @@ export default async function run(args: string[], cwd: string) { return 0; } - // Self-host: this IS the devkit repo. Auto-detected by package name β†’ a fixed, deterministic - // selection + source-mode hook, bypassing the wizard AND the flag path. Never a CLI flag (it only - // ever applies to one repo), and the auto-detect means a stray `devkit init` here can't add a - // self-dep or overwrite the bespoke config. + // Self-host is package-name detected and deterministic, bypassing wizard/flags to preserve its bespoke config. const selfHost = isDevkitRepo(cwd); if (selfHost) { mode = 'self-host'; @@ -1422,31 +1401,38 @@ export default async function run(args: string[], cwd: string) { detectedMode, structureAvailable: structureAvailableFor(detectedStack), installed, + existingReview: (readJson(join(cwd, '.devkit', 'config.json')) as DevkitConfig | null) + ?.review, }); if (!result) return 0; // cancelled β€” nothing written - ({ mode, stack, remove } = result); - // The wizard builds selection incrementally (Partial): package/standalone set every field; - // overlay leaves the package-only fields for applyOverlayConstraints (line below) to fill. It's - // a complete Selection at every point it's read here β€” no missing key is accessed before then. + ({ mode, stack, remove, review } = result); + // The wizard returns a complete selection after overlay constraints fill package-only fields. selection = result.selection as Selection; } else { selection = selectionFromFlags(flags); + } + + // Resolve overlay invariants before consumers validate or record them (Husky is always effective). + if (mode === 'overlay') selection = applyOverlayConstraints(selection); + if (!selfHost && !interactive) { + const reviewPlan = reviewPlanFromFlags(flags, selection); + if (reviewPlan.error) { + console.error(reviewPlan.error); + return 1; + } + review = reviewPlan.profile; // Non-interactive removal of deselected-present components only with --remove-deselected. if (flags.removeDeselected) { const installed = detectInstalled(cwd); for (const id of installed) { const stillSelected = - id === 'guards' ? selection.guards.length > 0 : selection[id as keyof Selection]; + id === 'devkit-review' || + (id === 'guards' ? selection.guards.length > 0 : selection[id as keyof Selection]); if (!stillSelected) remove.push(id); } } } - // Overlay offers the same opt-in component choices as package (via the wizard picker / --yes - // flags), but the components that can't work without the package are forced off and the local - // hook is forced on β€” applied here so the wizard AND the --yes/flag path get identical invariants. - if (mode === 'overlay') selection = applyOverlayConstraints(selection); - // Self-host runs structure via `bun run lint:structure` (eslint), not a template preset, so skip // the "no preset β†’ disable structure" flip (which would otherwise print a misleading notice). if (!selfHost && !structureAvailableFor(stack) && selection.structure) { @@ -1468,6 +1454,7 @@ export default async function run(args: string[], cwd: string) { overlay: mode === 'overlay', selfHost: mode === 'self-host', globalCommitGate: flags.globalCommitGate, + review, }); if (interactive && !selfHost) outro('Done β€” run `devkit doctor` to verify.'); return 0; diff --git a/cli/lib/components.mts b/cli/lib/components.mts index c2bf958d..017c39ad 100644 --- a/cli/lib/components.mts +++ b/cli/lib/components.mts @@ -27,6 +27,45 @@ export const RECOMMENDED_GUARD_IDS = [ */ export const GUARD_IDS = [...RECOMMENDED_GUARD_IDS, 'review', 'sentry', 'coverage']; +export const DEFAULT_REVIEW_DECISIONS_DIR = 'docs/decisions'; + +/** Local execution policy for `devkit review`; decision content stays in `decisionsDir`. */ +export interface ReviewProfile { + enabled: boolean; + guards: string[]; + decisionsDir: string; +} + +interface NormalizeReviewProfileOptions { + enabledDefault?: boolean; + available?: boolean; +} + +export function normalizeReviewProfile( + partial: Partial | undefined, + installedGuards: string[], + { enabledDefault = false, available = true }: NormalizeReviewProfileOptions = {}, +): ReviewProfile { + const installed = installedGuards.filter((g) => GUARD_IDS.includes(g)); + const requested = Array.isArray(partial?.guards) ? partial.guards : installed; + return { + enabled: available && (partial?.enabled ?? enabledDefault), + guards: installed.filter((g) => requested.includes(g)), + decisionsDir: + typeof partial?.decisionsDir === 'string' && partial.decisionsDir.trim() + ? partial.decisionsDir.trim() + : DEFAULT_REVIEW_DECISIONS_DIR, + }; +} + +/** Stacks whose structure rules are compiled from guard.config.json by devkit itself. */ +export const CONFIG_DRIVEN_STRUCTURE = new Set(['react-app', 'component-lib']); + +/** The structure-lint command emitted by init and checked by doctor/review preflight. */ +export function structureCmdFor(stack: string): string { + return CONFIG_DRIVEN_STRUCTURE.has(stack) ? 'guard-structure gate' : 'bunx eslint src'; +} + /** * The agent surfaces devkit can sync skills/agents/agent-hooks into: Claude (`.claude/`) and * Cursor (`.cursor/`). `selection.agentTargets` picks the subset to write to (default both) so a diff --git a/cli/lib/help/init-help.mts b/cli/lib/help/init-help.mts new file mode 100644 index 00000000..daf2c0b8 --- /dev/null +++ b/cli/lib/help/init-help.mts @@ -0,0 +1,32 @@ +export const INIT_HELP = `devkit init β€” wire this repo onto @norvalbv/devkit (interactive on a TTY, idempotent). + +Usage: + devkit init [options] + + --stack electron | react-app | next | node-service | generic + (default: auto-detect; structure preset ships for electron + react-app). + --yes Non-interactive: install all recommended defaults (no prompts). + --dry-run Print every file action; write nothing. + --force Overwrite existing devkit-managed files, AND adopt/overwrite a consumer's + own same-named skill/agent/hook collisions (default: preserve them). + --no- Skip a component: --no-biome --no-tsconfig --no-skills --no-husky + --no-structure --no-guards --no-fallow. + --guards Only these guards (subset of size,fanout,dup,clone,decisions, + qavis-advisory,review,sentry; review + sentry are opt-in, off by default). + --review Enable \`devkit review\` with an explicit local gate profile. + --no-review Disable \`devkit review\` for this installation. + --review-guards With --review: guard allowlist (default: installed guard selection). + --review-decisions-dir With --review: local decision store (default: docs/decisions). + --no-claude/--no-cursor Sync skills/agents/hooks to ONE agent surface only (default both). + --baselines-only Re-derive ONLY the structure + import-wall baselines (rare; after a + structure-RULE change). Package-mode structure stacks only. + --fallow Also install the optional fallow code-health layer (off by default). + --search-code Opt this repo in to the semantic search index (off by default). + --standalone NO-PACKAGE mode: vendor configs + a fail-open hook calling GLOBAL guard-* + bins; add nothing to package.json. Requires \`bun add -g\` devkit. + --overlay LOCAL-ONLY mode for a repo you can't modify: git-ignored, chains to the + repo's own hook, configs EXTEND the repo's. Requires global devkit. + --scan-root Override guard.config.json scanRoots up front (set BEFORE the freezes). + --remove-deselected With --yes: remove an installed-but-now-deselected component (opt-in). + +See docs/glossary.md for package/standalone/overlay, gates, ratchets, baselines, scanRoot.`; diff --git a/cli/lib/husky/husky-block.mts b/cli/lib/husky/husky-block.mts index 933698b7..ce11bd96 100644 --- a/cli/lib/husky/husky-block.mts +++ b/cli/lib/husky/husky-block.mts @@ -11,6 +11,11 @@ */ import { markEnd, markStart } from './husky.mts'; +import { + DK_GATE_SELECTED_HELPER, + DK_REVIEW_BASELINE_HELPER, + selectedFragment, +} from './review-fragments.mts'; /** * The block-builder's view of a component selection: whether the biome format step is wanted, @@ -89,11 +94,8 @@ fi const DETERMINISTIC_GUARD_IDS = ['size', 'fanout', 'dup', 'clone']; const AI_GUARD_IDS = ['decisions', 'review'] as const; -// The qavis-advisory gate runs LAST β€” it's advisory, not a blocker, and cheapest to skip past. It is -// NOT an AI_GUARD (different exit contract: 0 = continue, 3 = strict-ship block, never exit 1), so it -// gets its own fragment + its own remedy line rather than the shared "judge unavailable" copy. All -// the "deserves QA?" logic + the pass-receipt live in qavis; this just shells `qavis route` and maps -// its verdict to an exit code (fail-open when qavis/the bin is absent β€” the fallow precedent). +// qavis-advisory runs last with its own 0/3 exit contract; routing and pass receipts live in qavis. +// This wrapper stays fail-open when qavis/the bin is absent, matching the fallow precedent. const QAVIS_ADVISORY_ID = 'qavis-advisory'; const QAVIS_FRAGMENT = `# devkit:guard-qavis-advisory qarc=0 @@ -107,16 +109,9 @@ const standaloneQavisLines = `if command -v guard-qavis-advisory >/dev/null 2>&1 [ "$qarc" -eq 3 ] && exit 1 fi`; -// Terminal marker for the every-commit telemetry run (run-context.mts contract). A ship's -// ship_result is its terminal, but a plain commit's gates have no wrapper process β€” so the HOOK -// emits `commit_result` when it exits, and the usage dashboard settles the run immediately -// instead of waiting out a 35-minute quiet window (its fallback for hooks without this fragment). -// The tree hash is computed AT EMIT TIME, not when the trap is armed: the biome fragment restages -// formatted files, and the gates correlate under the POST-format `git write-tree`. Fail-open -// everywhere; same opt-outs as the capture itself (any DEVKIT_NO_TELEMETRY value disables; inside -// a ship DEVKIT_SHIP_ID is set and this stays silent β€” ship_result is that run's terminal). -// Caveat: claims the shell's single EXIT trap β€” a consumer hook defining its own EXIT trap after -// this block would replace it (none of devkit's fragments do). +// Plain commits lack ship's wrapper terminal, so the hook emits `commit_result` on EXIT. Compute the +// tree at emit time because biome may restage files; ships stay silent and emit `ship_result` instead. +// This claims the shell's EXIT trap (no other devkit fragment defines one). const COMMIT_TERMINAL_FRAGMENT = `# devkit:commit-terminal if [ -z "\${DEVKIT_SHIP_ID:-}" ] && [ -z "\${DEVKIT_NO_TELEMETRY:-}" ]; then __dk_t0="$(date +%s)" @@ -158,9 +153,7 @@ if [ -n "$STAGED_FMT" ]; then fi # /devkit:biome-format`; -// The PATH-setup snippet (GUI git clients launch with a minimal PATH that omits user bin dirs, so -// `bun`/`bunx` go missing). devkit's gates need it to have run BEFORE them β€” it's part of a fresh hook's -// preamble, and is INJECTED just ahead of an inserted block when an existing hook has no PATH setup. +// Inject user bins before gates when a GUI client's minimal PATH omits bun/bunx. export const PATH_SETUP = `# GUI git clients launch with a minimal PATH that omits user bin dirs, so \`bun\`/\`bunx\` # can go missing β†’ the hook fails. Prepend the standard user install locations. for dir in "$HOME/.bun/bin" "$HOME/.local/bin"; do @@ -206,26 +199,20 @@ function wantsDeterministic(selection: HookSelection): boolean { * aggregated report β†’ prefix record, all inside the bin) β†’ AI guards (fail-fast). * biome runs BEFORE the orchestrator on purpose: the cache key hashes the post-format index. * - * `pkgRel` (monorepo): when set, the markers are package-scoped and the gates run inside a - * `( cd "" … ) || exit 1` subshell so the hook (at the git root) governs that package. - * biome's staged-file format is REPO-WIDE β†’ emitted only at the root (pkgRel ''), never inside - * a package subshell (where `git add` paths would resolve wrong). - * - * `selection.structureCmd` is the stack-resolved structure-lint command (`guard-structure gate` / - * `bunx eslint src`), absent when structure is off. `pkgRel` is the package path relative to the - * git root ('' = root install). + * In a monorepo `pkgRel` scopes markers and gates to a failing subshell. Biome remains root-only + * because package-relative `git add` paths would be wrong. `structureCmd` is stack-resolved. */ export function buildGuardBlock(selection: HookSelection, pkgRel = ''): string { - const pieces = []; + const pieces = [COMMIT_TERMINAL_FRAGMENT, DK_GATE_SELECTED_HELPER, DK_REVIEW_BASELINE_HELPER]; // First so a first-gate block still records the run's terminal (the trap covers every exit path). - pieces.push(COMMIT_TERMINAL_FRAGMENT); if (!pkgRel && selection.biome) pieces.push(BIOME_FRAGMENT); if (wantsDeterministic(selection)) pieces.push(deterministicFragment(selection.structureCmd, selection.extras)); for (const id of AI_GUARD_IDS) { - if (selection.guards?.includes(id)) pieces.push(GUARD_FRAGMENTS[id]); + if (selection.guards?.includes(id)) pieces.push(selectedFragment(id, GUARD_FRAGMENTS[id])); } - if (selection.guards?.includes(QAVIS_ADVISORY_ID)) pieces.push(QAVIS_FRAGMENT); + if (selection.guards?.includes(QAVIS_ADVISORY_ID)) + pieces.push(selectedFragment(QAVIS_ADVISORY_ID, QAVIS_FRAGMENT)); const body = pieces.join('\n\n'); const start = markStart(pkgRel); const end = markEnd(pkgRel); @@ -265,15 +252,14 @@ const DK_GATE_AI_HELPER = '__dk_gate_ai() { command -v "$1" >/dev/null 2>&1 || return 0; rc=0; "$@" || rc=$?; if [ "$rc" -eq 3 ]; then echo " $1: judge unavailable β€” strict ship mode failed closed. Check claude auth/quota, then re-run devkit ship."; exit 1; elif [ "$rc" -eq 1 ] || { [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; }; then exit 1; fi; }'; /** - * Build the standalone (no-package) `# devkit-guards` block β€” global `guard-*` bins, fail-open, - * NO `bunx`/node_modules. biome-format is omitted (needs project-local tooling); structure-lint - * joins the orchestrator via `--structure` when `selection.structureCmd` (config-driven stacks β€” - * devkit's own eslint/plugin do the work, so no consumer dep). pkgRel cd-wraps for a monorepo. + * Build standalone gates from global fail-open bins. Biome needs local tooling and is omitted; + * structure joins via `--structure`, and `pkgRel` scopes monorepos. */ export function buildStandaloneBlock(selection: HookSelection, pkgRel = ''): string { const pieces = [ '# devkit standalone gates β€” global CLI, fail-open (skipped if devkit is not installed).', COMMIT_TERMINAL_FRAGMENT, + DK_GATE_SELECTED_HELPER, DK_GATE_AI_HELPER, ]; if (wantsDeterministic(selection)) { @@ -281,9 +267,12 @@ export function buildStandaloneBlock(selection: HookSelection, pkgRel = ''): str } for (const id of AI_GUARD_IDS) { if (selection.guards?.includes(id)) - pieces.push(`__dk_gate_ai ${STANDALONE_GATES[id].join(' ')}`); + pieces.push( + `if __dk_gate_selected ${id}; then __dk_gate_ai ${STANDALONE_GATES[id].join(' ')}; fi`, + ); } - if (selection.guards?.includes(QAVIS_ADVISORY_ID)) pieces.push(standaloneQavisLines); + if (selection.guards?.includes(QAVIS_ADVISORY_ID)) + pieces.push(selectedFragment(QAVIS_ADVISORY_ID, standaloneQavisLines)); const body = pieces.join('\n'); const start = markStart(pkgRel); const end = markEnd(pkgRel); @@ -304,10 +293,14 @@ export function buildStandaloneHook(selection: HookSelection, pkgRel = ''): stri // the hook runs at the repo root or cd'd into a monorepo package (eslint/biome + their configs // are then resolved package-locally). const OVERLAY_LINT_STEPS = `# devkit lint overlay β€” STAGED files only, against configs that EXTEND the repo's (git-ignored). -DK_TS=$(git diff --cached --name-only --relative --diff-filter=ACM | grep -E '\\.(tsx?|jsx?)$' || true) -if [ -n "$DK_TS" ] && [ -f eslint.config.devkit.mjs ] && [ -x node_modules/.bin/eslint ]; then - echo "🧱 devkit eslint overlay (staged)..." - echo "$DK_TS" | xargs node_modules/.bin/eslint -c eslint.config.devkit.mjs || exit 1 +if [ "\${DEVKIT_RUN_MODE:-}" = "review" ]; then + __dk_review_baseline_gate eslint || exit 1 +else + DK_TS=$(git diff --cached --name-only --relative --diff-filter=ACM | grep -E '\\.(tsx?|jsx?)$' || true) + if [ -n "$DK_TS" ] && [ -f eslint.config.devkit.mjs ] && [ -x node_modules/.bin/eslint ]; then + echo "🧱 devkit eslint overlay (staged)..." + echo "$DK_TS" | xargs node_modules/.bin/eslint -c eslint.config.devkit.mjs || exit 1 + fi fi DK_FMT=$(git diff --cached --name-only --relative --diff-filter=ACM | grep -E '\\.(tsx?|jsx?|css|jsonc?)$' || true) if [ -n "$DK_FMT" ] && [ -f biome.devkit.jsonc ] && [ -x node_modules/.bin/biome ]; then @@ -315,16 +308,16 @@ if [ -n "$DK_FMT" ] && [ -f biome.devkit.jsonc ] && [ -x node_modules/.bin/biome echo "$DK_FMT" | xargs node_modules/.bin/biome check --config-path biome.devkit.jsonc || exit 1 fi`; -// The optional fallow gate (overlay). fail-open β€” only runs if fallow is on PATH; overlay's -// core.hooksPath takeover SHADOWS .git/hooks, so fallow's own installed hook would never fire, and -// chaining the gate inline here is the only way the audit runs. `fallow audit` exits non-zero on -// NEW issues (pre-existing debt is grandfathered by the saved fallow-baselines/). -const FALLOW_OVERLAY_GATE = `# devkit fallow gate (overlay) β€” fail-open; skipped if fallow isn't installed. -# DEVKIT_SHIP_BASE_SHA (set by devkit ship) narrows the audit to the exact ship base rather than -# fallow's own main-autodetect β€” see self-host.mts's FALLOW_FRAGMENT for the full rationale (DK-5). -FALLOW_BASE_ARGS="" -[ -n "\${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA" -command -v fallow >/dev/null 2>&1 && { fallow audit $FALLOW_BASE_ARGS || exit 1; }`; +// Overlay shadows fallow's installed hook, so its optional audit must run inline here. +const FALLOW_OVERLAY_GATE = `# devkit fallow gate (overlay) β€” normal commits fail-open if fallow isn't installed. +if [ "\${DEVKIT_RUN_MODE:-}" = "review" ]; then + __dk_review_baseline_gate fallow || exit 1 +else + # Pin ships to their exact worktree base (DK-5); plain commits retain Fallow's base discovery. + FALLOW_BASE_ARGS="" + [ -n "\${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA" + command -v fallow >/dev/null 2>&1 && { fallow audit $FALLOW_BASE_ARGS || exit 1; } +fi`; /** * Build the OVERLAY hook β€” a complete, self-contained file devkit fully owns (written to a @@ -332,10 +325,8 @@ command -v fallow >/dev/null 2>&1 && { fallow audit $FALLOW_BASE_ARGS || exit 1; * gates + lint overlay (cd'd into the package for a monorepo), then `exec`s the repo's OWN * committed hook unchanged (so its exit propagates). * - * `chainTarget` is the repo's existing hook to chain to (git-root-relative). `pkgRel` is the - * package dir (monorepo) to cd into before the gates ('' = repo root). `opts.fallow` appends the - * fail-open fallow gate (overlay wires it inline because core.hooksPath shadows fallow's own - * .git/hooks hook). + * `chainTarget` is the existing hook, `pkgRel` scopes monorepos, and `opts.fallow` adds the inline + * audit that the overlay hooksPath would otherwise shadow. */ export function buildOverlayHook( selection: HookSelection, @@ -343,13 +334,21 @@ export function buildOverlayHook( pkgRel = '', { fallow = false }: { fallow?: boolean } = {}, ): string { - const gates = [COMMIT_TERMINAL_FRAGMENT, DK_GATE_AI_HELPER]; + const gates = [ + COMMIT_TERMINAL_FRAGMENT, + DK_GATE_SELECTED_HELPER, + DK_GATE_AI_HELPER, + DK_REVIEW_BASELINE_HELPER, + ]; if (wantsDeterministic(selection)) gates.push(standaloneDeterministicLines()); for (const id of AI_GUARD_IDS) { if (selection.guards?.includes(id)) - gates.push(`__dk_gate_ai ${STANDALONE_GATES[id].join(' ')}`); + gates.push( + `if __dk_gate_selected ${id}; then __dk_gate_ai ${STANDALONE_GATES[id].join(' ')}; fi`, + ); } - if (selection.guards?.includes(QAVIS_ADVISORY_ID)) gates.push(standaloneQavisLines); + if (selection.guards?.includes(QAVIS_ADVISORY_ID)) + gates.push(selectedFragment(QAVIS_ADVISORY_ID, standaloneQavisLines)); const inner = `${gates.join('\n')}\n\n${OVERLAY_LINT_STEPS}${fallow ? `\n\n${FALLOW_OVERLAY_GATE}` : ''}`; const scoped = pkgRel ? `DK_HOOK_PATH="$(cd "$(dirname -- "$0")" >/dev/null 2>&1 && pwd)/$(basename -- "$0")"\n( cd ${JSON.stringify(pkgRel)} || exit 1\n${inner}\n) || exit 1` diff --git a/cli/lib/husky/review-drift.mts b/cli/lib/husky/review-drift.mts new file mode 100644 index 00000000..efdd1081 --- /dev/null +++ b/cli/lib/husky/review-drift.mts @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { normalizeSelection, type Selection, structureCmdFor } from '../components.mts'; +import { detectGitRoot } from '../detect-git-root.mts'; +import { readJson } from '../fs-helpers.mts'; +import { syncOverlayHook } from '../overlay.mts'; +import { buildGuardBlock, buildStandaloneBlock, extractGuardBlock } from './husky-block.mts'; +import { + buildSelfHostBlock, + SELF_HOST_EXTRAS, + SELF_HOST_STRUCTURE_CMD, + selfHostSelection, +} from './self-host.mts'; + +interface ReviewSetupConfig { + overlay?: boolean; + standalone?: boolean; + selfHost?: boolean; + stack?: string; + pkgRel?: string; + origHooksPath?: string; + components?: Partial; +} + +/** Exact generator-backed hook drift check used before `devkit review` executes target code. */ +export function reviewHookDrift(cwd: string): string | null { + const cfg = readJson(join(cwd, '.devkit', 'config.json')); + if (!cfg) return 'missing .devkit/config.json'; + const { gitRoot, pkgRel } = detectGitRoot(cwd); + if (cfg.overlay) { + const sync = syncOverlayHook(gitRoot, cwd, cfg, { dryRun: true }); + return sync.drift ? 'overlay pre-commit differs from the current generator' : null; + } + + const hookPath = join(gitRoot, '.husky', 'pre-commit'); + if (!existsSync(hookPath)) return 'missing .husky/pre-commit'; + const current = extractGuardBlock(readFileSync(hookPath, 'utf8'), pkgRel); + const selection = normalizeSelection(cfg.components ?? {}); + const expected = cfg.selfHost + ? buildSelfHostBlock( + { ...selfHostSelection(), structureCmd: SELF_HOST_STRUCTURE_CMD, extras: SELF_HOST_EXTRAS }, + pkgRel, + cwd, + ) + : (cfg.standalone ? buildStandaloneBlock : buildGuardBlock)( + { + ...selection, + structureCmd: selection.structure ? structureCmdFor(cfg.stack ?? 'generic') : undefined, + }, + pkgRel, + ); + return current !== null && current.trim() === expected.trim() + ? null + : 'pre-commit gate block differs from the current generator'; +} diff --git a/cli/lib/husky/review-fragments.mts b/cli/lib/husky/review-fragments.mts new file mode 100644 index 00000000..9621547c --- /dev/null +++ b/cli/lib/husky/review-fragments.mts @@ -0,0 +1,40 @@ +/** Shell fragments shared by package, standalone, overlay, and self-host review hooks. */ + +// Review mode has its own positive guard allowlist. Normal commit/ship runs select everything in +// the generated hook; review runs only ids named by DEVKIT_REVIEW_GUARDS. +export const DK_GATE_SELECTED_HELPER = `__dk_gate_selected() { + [ "\${DEVKIT_RUN_MODE:-}" != "review" ] && return 0 + __dk_review_guards=$(printf '%s' "\${DEVKIT_REVIEW_GUARDS:-}" | sed \ + -e 's/[[:space:]]*,[[:space:]]*/,/g' \ + -e 's/^[[:space:]]*//' \ + -e 's/[[:space:]]*$//') + case ",\${__dk_review_guards}," in + *,"$1",*) return 0 ;; + *) return 1 ;; + esac +}`; + +export function selectedFragment(id: string, fragment: string): string { + return `if __dk_gate_selected ${id}; then +${fragment} +fi`; +} + +// Review snapshots need a fresh merge-base comparison rather than the consumer's potentially stale +// persisted baselines. The driver exports the current package root + an invocation-unique runtime; +// a missing helper is setup drift and must block rather than silently falling back to raw lint. +export const DK_REVIEW_BASELINE_HELPER = `__dk_review_baseline_gate() { + [ -n "\${DEVKIT_REVIEW_BASELINE_DIR:-}" ] || { + echo "devkit review: merge-base baseline runtime is missing β€” reinstall/rebuild Devkit." >&2 + return 1 + } + __dk_baseline_gate= + for __dk_candidate in "\${DEVKIT_REVIEW_PACKAGE_ROOT:-}/gate-engine/review/baseline-gate.mjs" "\${DEVKIT_REVIEW_PACKAGE_ROOT:-}/gate-engine/review/baseline-gate.mts"; do + if [ -f "$__dk_candidate" ]; then __dk_baseline_gate=$__dk_candidate; break; fi + done + [ -n "$__dk_baseline_gate" ] || { + echo "devkit review: baseline helper is missing β€” reinstall/rebuild Devkit." >&2 + return 1 + } + node "$__dk_baseline_gate" "$1" "$DEVKIT_REVIEW_BASELINE_DIR" +}`; diff --git a/cli/lib/husky/self-host.mts b/cli/lib/husky/self-host.mts index b6384249..a8fcf267 100644 --- a/cli/lib/husky/self-host.mts +++ b/cli/lib/husky/self-host.mts @@ -52,14 +52,14 @@ export const SELF_HOST_EXTRAS: Array<{ label: string; cmd: string }> = [ // covers it too. const FALLOW_FRAGMENT = `# devkit:fallow-advisory # fallow audit β€” dead-code / duplication / complexity on the changed set; advisory, never blocks. -# DEVKIT_SHIP_BASE_SHA (exported by devkit ship β€” see commit-with-gate-capture.sh) pins the audit's -# comparison ref to the exact commit the ship worktree was cut from, instead of fallow's own -# main-autodetect: a --base ship off a long-lived/stacked branch would otherwise misreport that -# branch's own pre-existing findings vs main as "new" (DK-5). Unset on a plain \`git commit\` β€” fallow -# falls back to its own default. -FALLOW_BASE_ARGS="" -[ -n "\${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA" -command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true +if [ "\${DEVKIT_RUN_MODE:-}" = "review" ]; then + __dk_review_baseline_gate fallow || true +else + # Pin ships to their exact worktree base (DK-5); plain commits retain Fallow's base discovery. + FALLOW_BASE_ARGS="" + [ -n "\${DEVKIT_SHIP_BASE_SHA:-}" ] && FALLOW_BASE_ARGS="--base $DEVKIT_SHIP_BASE_SHA" + command -v fallow >/dev/null 2>&1 && fallow audit $FALLOW_BASE_ARGS || true +fi # /devkit:fallow-advisory`; // The hook-builder's view of the self-host selection (Selection + the two hook-only fields the diff --git a/cli/lib/install/review-profile.mts b/cli/lib/install/review-profile.mts new file mode 100644 index 00000000..318d3ee1 --- /dev/null +++ b/cli/lib/install/review-profile.mts @@ -0,0 +1,72 @@ +import { + DEFAULT_REVIEW_DECISIONS_DIR, + GUARD_IDS, + type ReviewProfile, + type Selection, +} from '../components.mts'; + +export interface ReviewFlagValues { + review: boolean | null; + reviewGuards: string[] | null; + reviewDecisionsDir: string | null; +} + +/** Parse the review-profile slice independently so init orchestration stays below its size ceiling. */ +export function parseReviewFlags(args: string[]): ReviewFlagValues { + const flags: ReviewFlagValues = { + review: null, + reviewGuards: null, + reviewDecisionsDir: null, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--review') flags.review = true; + else if (args[i] === '--no-review') flags.review = false; + else if (args[i] === '--review-guards') + flags.reviewGuards = (args[++i] ?? '') + .split(',') + .map((guard) => guard.trim()) + .filter(Boolean); + else if (args[i] === '--review-decisions-dir') flags.reviewDecisionsDir = args[++i] ?? ''; + } + return flags; +} + +export function reviewPlanFromFlags( + flags: ReviewFlagValues, + selection: Selection, +): { profile?: Partial; error?: string } { + if (flags.review === null && flags.reviewGuards === null && flags.reviewDecisionsDir === null) + return {}; + const modifiers = [ + flags.reviewGuards !== null ? '--review-guards' : '', + flags.reviewDecisionsDir !== null ? '--review-decisions-dir' : '', + ].filter(Boolean); + if (flags.review !== true && modifiers.length > 0) { + return { error: `devkit init: ${modifiers.join(' and ')} require --review.` }; + } + if (flags.review !== false && !selection.husky) { + return { + error: 'devkit init: --review requires the husky pre-commit component (remove --no-husky).', + }; + } + const unknown = (flags.reviewGuards ?? []).filter((guard) => !GUARD_IDS.includes(guard)); + const uninstalled = (flags.reviewGuards ?? []).filter( + (guard) => GUARD_IDS.includes(guard) && !selection.guards.includes(guard), + ); + if (unknown.length > 0 || uninstalled.length > 0) { + const details = [ + unknown.length ? `unknown: ${unknown.join(', ')}` : '', + uninstalled.length ? `not selected by --guards: ${uninstalled.join(', ')}` : '', + ] + .filter(Boolean) + .join('; '); + return { error: `devkit init: invalid --review-guards (${details}).` }; + } + return { + profile: { + enabled: flags.review ?? true, + guards: flags.reviewGuards ?? selection.guards, + decisionsDir: flags.reviewDecisionsDir || DEFAULT_REVIEW_DECISIONS_DIR, + }, + }; +} diff --git a/cli/lib/wizard.mts b/cli/lib/wizard.mts index cac3e4fa..cf13e4cb 100644 --- a/cli/lib/wizard.mts +++ b/cli/lib/wizard.mts @@ -13,8 +13,10 @@ import { cancel, confirm, intro, isCancel, multiselect, note, select } from '@cl import { AGENT_TARGETS, COMPONENTS, + DEFAULT_REVIEW_DECISIONS_DIR, GUARD_OPTIONS, RECOMMENDED_GUARD_IDS, + type ReviewProfile, type Selection, } from './components.mts'; @@ -120,6 +122,8 @@ interface RunWizardOpts { structureAvailable: boolean; /** component ids currently wired (so we can offer removal) */ installed: Set; + /** persisted review policy fields that the wizard does not expose */ + existingReview?: Partial; } // The plan the wizard hands back to init (which normalises `selection` into a full Selection). The @@ -129,6 +133,7 @@ interface WizardResult { stack: string; selection: Partial; remove: string[]; + review: ReviewProfile; } // Reason: flat clack wizard orchestration: sequential numbered steps (modeβ†’stackβ†’componentsβ†’guardsβ†’removalβ†’summaryβ†’apply) each guarded by `if (bail(x)) return null`; the branch COUNT is high but every branch is near-flat, and the untested-complexity is acceptable because this is an interactive TTY prompt flow exercised end-to-end, not unit-tested @@ -138,6 +143,7 @@ export async function runWizard({ detectedMode = 'package', structureAvailable, installed, + existingReview, }: RunWizardOpts): Promise { intro('β—† devkit setup'); @@ -244,6 +250,39 @@ export async function runWizard({ selection.guards = (guards as string[]).filter((g) => g !== LINE_GROWTH_ID); } + // Review execution is a separate local policy from ordinary commit/ship guards. Opt-in here; + // when enabled, make the positive allowlist explicit so future gates never enter cron reviews. + const reviewEnabled = selection.husky + ? await confirm({ + message: 'Enable devkit review?', + initialValue: installed.has('devkit-review'), + }) + : false; + if (bail(reviewEnabled)) return null; + let reviewGuards: string[] = []; + if (reviewEnabled) { + const options = GUARD_OPTIONS.filter((g) => selection.guards?.includes(g.id)).map((g) => ({ + value: g.id, + label: g.label, + hint: g.hint, + })); + if (options.length > 0) { + const picked = await multiselect({ + message: 'Select guards for devkit review', + options, + initialValues: [...(selection.guards ?? [])], + required: false, + }); + if (bail(picked)) return null; + reviewGuards = picked as string[]; + } + } + const review: ReviewProfile = { + enabled: Boolean(reviewEnabled), + guards: reviewGuards, + decisionsDir: existingReview?.decisionsDir?.trim() || DEFAULT_REVIEW_DECISIONS_DIR, + }; + // 5. Removal: package/standalone only (overlay is local-only β€” a re-run just overwrites). const remove = []; const deselected = @@ -251,7 +290,8 @@ export async function runWizard({ ? [] : [...installed].filter((id) => { const stillSelected = - id === 'guards' ? (selection.guards ?? []).length > 0 : selection[id]; + id === 'devkit-review' || + (id === 'guards' ? (selection.guards ?? []).length > 0 : selection[id]); return !stillSelected; }); @@ -275,7 +315,7 @@ export async function runWizard({ if (yes) remove.push(id); } - return { mode, stack, selection, remove }; + return { mode, stack, selection, remove, review }; } // Concise plan summary for the note(): a βœ“/Β· line per component + a remove line. diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 6eb069c5..660e108f 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -103,6 +103,7 @@ Committed evidence rejects raw prompts, transcripts, absolute paths, email addre | Testing reviewer agent | agent | shipped | none | β€” | | devkit CLI | bin | shipped | none | β€” | | Clone gate | bin | shipped | evidence-only | co-occurrence | +| Coverage gate | bin | shipped | none | β€” | | Decisions gate | bin | shipped | evidence-only | decisions | | Deterministic orchestrator | bin | shipped | none | β€” | | Semantic duplication gate | bin | shipped | evidence-only | co-occurrence | diff --git a/docs/benchmarks/catalog.json b/docs/benchmarks/catalog.json index 93a970f6..8ca1d675 100644 --- a/docs/benchmarks/catalog.json +++ b/docs/benchmarks/catalog.json @@ -17,6 +17,7 @@ { "id": "bin-devkit", "label": "devkit CLI", "kind": "bin", "lifecycle": "shipped", "evidence": "none", "suiteIds": [], "canonical": "package.json#bin:devkit" }, { "id": "bin-guard-clone", "label": "Clone gate", "kind": "bin", "lifecycle": "shipped", "evidence": "evidence-only", "suiteIds": ["co-occurrence"], "canonical": "package.json#bin:guard-clone" }, + { "id": "bin-guard-coverage", "label": "Coverage gate", "kind": "bin", "lifecycle": "shipped", "evidence": "none", "suiteIds": [], "canonical": "package.json#bin:guard-coverage" }, { "id": "bin-guard-decisions", "label": "Decisions gate", "kind": "bin", "lifecycle": "shipped", "evidence": "evidence-only", "suiteIds": ["decisions"], "canonical": "package.json#bin:guard-decisions" }, { "id": "bin-guard-deterministic", "label": "Deterministic orchestrator", "kind": "bin", "lifecycle": "shipped", "evidence": "none", "suiteIds": [], "canonical": "package.json#bin:guard-deterministic" }, { "id": "bin-guard-dup", "label": "Semantic duplication gate", "kind": "bin", "lifecycle": "shipped", "evidence": "evidence-only", "suiteIds": ["co-occurrence"], "canonical": "package.json#bin:guard-dup" }, diff --git a/eslint/baselines/size-lines.json b/eslint/baselines/size-lines.json index 8e73c82f..9731debd 100644 --- a/eslint/baselines/size-lines.json +++ b/eslint/baselines/size-lines.json @@ -1,15 +1,15 @@ { "maxLines": 500, "files": { - "cli/commands/doctor.mts": 890, - "cli/commands/init.mts": 1477, + "cli/commands/doctor.mts": 889, + "cli/commands/init.mts": 1464, "cli/lib/generate/generate-structure-baseline.mts": 876, - "cli/lib/husky/husky-block.mts": 528, + "cli/lib/husky/husky-block.mts": 527, "cli/lib/overlay.mts": 561, "gate-engine/co-occurrence/matcher.mts": 543, "gate-engine/critique/eval/bench.mts": 1177, "gate-engine/decisions/decisions.mts": 708, - "gate-engine/decisions/detect.mts": 525, + "gate-engine/decisions/detect.mts": 550, "gate-engine/decisions/eval/bench.mts": 1059, "gate-engine/edge-cases/eval/lib/sources.mts": 571, "gate-engine/review/eval/bench.mts": 878, diff --git a/gate-engine/deterministic/__tests__/run.test.mts b/gate-engine/deterministic/__tests__/run.test.mts index 6198d957..a9861e7a 100644 --- a/gate-engine/deterministic/__tests__/run.test.mts +++ b/gate-engine/deterministic/__tests__/run.test.mts @@ -2,11 +2,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { parseOpts, runDeterministic, selectedIds } from '../run.mts'; +import { parseOpts, prefixCacheScope, runDeterministic, selectedIds } from '../run.mts'; const dirs = []; afterEach(() => { while (dirs.length) rmSync(dirs.pop(), { recursive: true, force: true }); + delete process.env.DEVKIT_RUN_MODE; + delete process.env.DEVKIT_REVIEW_GUARDS; + delete process.env.DEVKIT_SHIP; vi.restoreAllMocks(); }); @@ -159,6 +162,28 @@ describe('runDeterministic β€” --structure / --extra / --only', () => { expect(err.mock.calls.flat().join('\n')).toContain('empty selection'); expect(exec).not.toHaveBeenCalled(); }); + + it('review --only cannot re-enable a gate outside the configured review allowlist', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exec = mkExec({}); + process.env.DEVKIT_RUN_MODE = 'review'; + process.env.DEVKIT_REVIEW_GUARDS = 'size'; + + expect(runDeterministic(repo(['size', 'fanout']), { exec, only: ['fanout'] })).toBe(1); + expect(err.mock.calls.flat().join('\n')).toContain('not enabled for review: fanout'); + expect(exec).not.toHaveBeenCalled(); + }); + + it('review --only may narrow the allowlist and runs the canonical subset once', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const exec = mkExec({}); + process.env.DEVKIT_RUN_MODE = 'review'; + process.env.DEVKIT_REVIEW_GUARDS = 'fanout,size'; + + expect(runDeterministic(repo(['clone']), { exec, only: ['fanout', 'fanout'] })).toBe(0); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec.mock.calls[0][1][0]).toContain('folder-fanout'); + }); }); describe('parseOpts β€” the argv tokenizer the real hook depends on', () => { @@ -207,6 +232,17 @@ describe('selectedIds', () => { it('EXCLUDES opt-in coverage from the missing-config fallback (unadopted repo never wedged)', () => { expect(selectedIds(repo(null))).toEqual(['size', 'fanout', 'dup', 'clone']); }); + + it('uses the explicit review allowlist instead of components.guards in review mode', () => { + const d = repo(['size', 'fanout', 'dup', 'clone']); + process.env.DEVKIT_RUN_MODE = 'review'; + process.env.DEVKIT_REVIEW_GUARDS = ' clone, size ,decisions '; + expect(selectedIds(d)).toEqual(['size', 'clone']); + process.env.DEVKIT_REVIEW_GUARDS = ''; + expect(selectedIds(d)).toEqual([]); + delete process.env.DEVKIT_REVIEW_GUARDS; + expect(selectedIds(d)).toEqual([]); + }); }); describe('coverage β€” opt-in wiring through runDeterministic', () => { @@ -225,3 +261,24 @@ describe('coverage β€” opt-in wiring through runDeterministic', () => { expect(exec.mock.calls.some(([, argv]) => argv[0].includes('coverage/run'))).toBe(false); }); }); + +describe('prefixCacheScope', () => { + it('salts review entries with mode and allowlist while leaving commit/ship scopes unchanged', () => { + expect(prefixCacheScope()).toBeUndefined(); + expect(prefixCacheScope('custom')).toBe('custom'); + process.env.DEVKIT_RUN_MODE = 'review'; + process.env.DEVKIT_REVIEW_GUARDS = 'size,decisions'; + expect(prefixCacheScope()).toBe('devkit-guards:review:size'); + expect(prefixCacheScope('custom')).toBe('custom:review:size'); + }); + + it('canonicalizes the effective guard set so order and duplicates share one cache key', () => { + process.env.DEVKIT_RUN_MODE = 'review'; + expect(prefixCacheScope(undefined, ['fanout', 'size', 'fanout'])).toBe( + 'devkit-guards:review:size,fanout', + ); + expect(prefixCacheScope(undefined, ['size', 'fanout'])).toBe( + 'devkit-guards:review:size,fanout', + ); + }); +}); diff --git a/gate-engine/deterministic/run.mts b/gate-engine/deterministic/run.mts index 3704165b..3e3ae937 100644 --- a/gate-engine/deterministic/run.mts +++ b/gate-engine/deterministic/run.mts @@ -74,6 +74,19 @@ const ALL_IDS = DETERMINISTIC.map((g) => g.id); // and an explicit components.guards selection can still run opt-in guards (they're in ALL_IDS). const DEFAULT_IDS = DETERMINISTIC.filter((g) => !('optIn' in g && g.optIn)).map((g) => g.id); +function canonicalIds(ids: string[]): string[] { + const selected = new Set(ids); + return ALL_IDS.filter((id) => selected.has(id)); +} + +function reviewIds(): string[] { + const configured = (process.env.DEVKIT_REVIEW_GUARDS ?? '') + .split(',') + .map((guard) => guard.trim()) + .filter(Boolean); + return canonicalIds(configured); +} + // Split a `--structure` / `--extra` command string into argv tokens. Hoisted (perf: no per-call // regex compile). const WHITESPACE_RE = /\s+/; @@ -138,6 +151,7 @@ export function parseOpts(argv: string[]): ParsedOpts { // never silently skip a gate the hook expected to run β€” but opt-in guards (coverage) run ONLY when // explicitly selected, so an unadopted/CI repo is never wedged by a gate it never asked for. export function selectedIds(cwd: string): string[] { + if (process.env.DEVKIT_RUN_MODE === 'review') return reviewIds(); const cfgPath = path.join(cwd, '.devkit', 'config.json'); if (!existsSync(cfgPath)) return DEFAULT_IDS; try { @@ -149,6 +163,12 @@ export function selectedIds(cwd: string): string[] { } } +export function prefixCacheScope(scope?: string, effectiveIds?: string[]): string | undefined { + return process.env.DEVKIT_RUN_MODE === 'review' + ? `${scope ?? 'devkit-guards'}:review:${canonicalIds(effectiveIds ?? reviewIds()).join(',')}` + : scope; +} + // Run one gate as a subprocess; return its exit code (0 on success). stdio inherited so the gate's // own banner/output reaches the user exactly as it did when the hook invoked it directly. function runArgv(cwd: string, argv: string[], exec = execFileSync): number { @@ -188,27 +208,41 @@ function commandGate(label: string, cmd?: string): Gate { */ export function runDeterministic(cwd = process.cwd(), opts: RunDeterministicOpts = {}) { const { exec = execFileSync } = opts; + // `--only` is an execution narrowing request, never an authority grant. Validate it before cache + // lookup, then intersect it with review's positive allowlist so a crafted hook cannot re-enable a + // guard excluded by local review policy. + if (opts.only) { + const unknown = opts.only.filter((id) => !ALL_IDS.includes(id)); + if (unknown.length || opts.only.length === 0) { + const why = unknown.length ? `unknown gate id(s): ${unknown.join(', ')}` : 'empty selection'; + console.error( + `βœ— guard-deterministic --only: ${why} (known: ${ALL_IDS.join(', ')}) β€” refusing to run.`, + ); + return 1; + } + } + const reviewMode = process.env.DEVKIT_RUN_MODE === 'review'; + const allowed = reviewMode ? reviewIds() : selectedIds(cwd); + if (reviewMode && opts.only) { + const allowlist = new Set(allowed); + const disallowed = opts.only.filter((id) => !allowlist.has(id)); + if (disallowed.length > 0) { + console.error( + `βœ— guard-deterministic --only: gate id(s) not enabled for review: ${[...new Set(disallowed)].join(', ')} β€” refusing to run.`, + ); + return 1; + } + } + const effectiveIds = canonicalIds(opts.only ?? allowed); + // A review may intentionally run a strict subset. Salt the prefix scope so that subset can never + // authorize a later full ship/commit against the same staged tree. + const cacheScope = prefixCacheScope(opts.scope, effectiveIds); // Deterministic-prefix cache (ship only β€” a no-op otherwise): a cached all-green staged tree skips // every gate. checkPrefix returns true = skip, false = run. - const skip = checkPrefix(cwd, { hookPath: opts.hookPath, scope: opts.scope }); + const skip = checkPrefix(cwd, { hookPath: opts.hookPath, scope: cacheScope }); const fails = []; if (!skip) { - // `--only`, when provided, must name known guard ids. A typo (`--only siz,fanout`) or an empty - // spec (`--only ,,`) would otherwise filter DETERMINISTIC down to nothing and silently drop a - // required gate β€” the exact fail-open this orchestrator exists to prevent. Fail CLOSED, loudly. - if (opts.only) { - const unknown = opts.only.filter((id) => !ALL_IDS.includes(id)); - if (unknown.length || opts.only.length === 0) { - const why = unknown.length - ? `unknown gate id(s): ${unknown.join(', ')}` - : 'empty selection'; - console.error( - `βœ— guard-deterministic --only: ${why} (known: ${ALL_IDS.join(', ')}) β€” refusing to run.`, - ); - return 1; - } - } - const ids = new Set(opts.only ?? selectedIds(cwd)); + const ids = new Set(effectiveIds); const gates: Gate[] = DETERMINISTIC.filter((g) => ids.has(g.id)).map((g) => ({ label: `guard-${g.id}`, argv: ['node', path.resolve(HERE, g.module.replace(MJS_EXT_RE, SELF_EXT)), ...g.args], @@ -246,7 +280,7 @@ export function runDeterministic(cwd = process.cwd(), opts: RunDeterministicOpts } // All green (or a prefix-skip, already recorded): record the key so an identical staged tree skips // next time (ship only β€” recordPrefix is a no-op otherwise). - if (!skip) recordPrefix(cwd, { hookPath: opts.hookPath, scope: opts.scope }); + if (!skip) recordPrefix(cwd, { hookPath: opts.hookPath, scope: cacheScope }); return 0; }