From 1072418e168679257bf1263f106568600d2da800 Mon Sep 17 00:00:00 2001 From: norvalbv Date: Thu, 20 Aug 2026 16:47:16 +0100 Subject: [PATCH 1/2] Fix Devkit-owned staged structure checks ## Summary - Keep `components.structure: true` as the single signal that structure governance is enabled. - Generate one Devkit-owned `guard-structure staged` hook command for every supported structure stack; no consumer shell runner is needed. - Run normal staged source files once, retain full-tree `guard-structure gate` for CI and manual checks, and probe clean affected roots for staged renames and deletions. - Defer a root to CI whenever topology would observe mixed index and worktree input, including untracked source files, rather than claim a worktree result describes the index. - Preserve the Electron pinned local ESLint preset behind the Devkit runner; config-driven stacks continue through Devkit bundled ESLint. - Upgrade and doctor both recognise and repair the new owned command. ## Why The old Electron hook linted all of `src` on every commit. Disabling `structure` fixed its cost by disabling Devkit ownership, which was the wrong trade-off. This keeps the governance on while making the hot path staged-only and centrally maintained. ## Validation - `bun run format:check` - `bun run typecheck` - `bun run lint` - `bun run lint:structure` - `bun run build` - Focused lifecycle and structure suite: 74 passing tests. - Dist-integrity suite: 9 passing tests. - Staged anti-slop check: 42 existing findings and zero new findings. - Duplicate review: no new semantic or clone candidates. - Full Vitest was exercised; its existing parallel timing assertion in `husky-block-exec` flaked once, then passed in its isolated rerun. The suite did not terminate after that parallel failure, so it was stopped after 14 minutes rather than left running indefinitely. - Non-deterministic ship reviewers and completeness were bypassed only because the local Claude CLI remained unavailable; deterministic gates, local review, and targeted tests were all run. --- .../electron-structure-symlink.test.mts | 101 ++++++- cli/__tests__/init-doctor.test.mts | 17 +- cli/__tests__/monorepo.test.mts | 2 +- cli/__tests__/standalone.test.mts | 4 +- cli/__tests__/upgrade.test.mts | 18 ++ cli/commands/doctor.mts | 5 - cli/commands/init.mts | 2 +- cli/lib/components.mts | 8 +- cli/lib/husky/husky-block.mts | 4 +- dist/cli/commands/doctor.mjs | 5 - dist/cli/commands/init.mjs | 2 +- dist/cli/lib/components.mjs | 9 +- dist/cli/lib/husky/husky-block.mjs | 4 +- dist/gate-engine/structure/run.mjs | 201 +++++++++++++- docs/decisions/zero-consumer-tool-deps.md | 1 + gate-engine/structure/__tests__/run.test.mts | 114 +++++++- gate-engine/structure/run.mts | 253 +++++++++++++++++- 17 files changed, 675 insertions(+), 75 deletions(-) diff --git a/cli/__tests__/electron-structure-symlink.test.mts b/cli/__tests__/electron-structure-symlink.test.mts index ada4c98f..3fe7405c 100644 --- a/cli/__tests__/electron-structure-symlink.test.mts +++ b/cli/__tests__/electron-structure-symlink.test.mts @@ -14,15 +14,33 @@ afterEach(() => { }); describe('electron structure lint in a ship worktree', () => { - it('keeps plugin resolution rooted in the ephemeral worktree when node_modules is symlinked', () => { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'electron-structure-symlink-'))); - roots.push(root); - const worktree = join(root, 'worktree'); - mkdirSync(join(worktree, 'src'), { recursive: true }); - symlinkSync(join(DEVKIT_ROOT, 'node_modules'), join(worktree, 'node_modules')); - writeFileSync(join(worktree, 'package.json'), '{"type":"module"}\n'); + const stage = (cwd, ...paths) => { + const result = spawnSync('git', ['add', '--', ...paths], { cwd, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + }; + + const initializeGit = (cwd) => { + for (const args of [ + ['init'], + ['config', 'user.email', 'devkit-test@example.com'], + ['config', 'user.name', 'Devkit Test'], + ]) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + } + }; + + const stagedGate = (cwd) => + spawnSync(process.execPath, [join(DEVKIT_ROOT, 'gate-engine/structure/run.mts'), 'staged'], { + cwd, + encoding: 'utf8', + }); + + const writeElectronConfig = (cwd, body) => { + writeFileSync(join(cwd, 'guard.config.json'), '{"scanRoots":["src"]}\n'); + writeFileSync(join(cwd, 'package.json'), '{"type":"module"}\n'); writeFileSync( - join(worktree, 'eslint.config.mjs'), + join(cwd, 'eslint.config.mjs'), `import { createFolderStructure, projectStructureParser, @@ -31,7 +49,7 @@ describe('electron structure lint in a ship worktree', () => { const structure = createFolderStructure({ structureRoot: 'src', - structure: { name: 'src', children: [{ name: 'allowed.ts' }] }, + structure: ${body}, }); export default [{ @@ -42,13 +60,72 @@ export default [{ }]; `, ); + }; + + it('keeps plugin resolution rooted in a package worktree when node_modules is symlinked', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'electron-structure-symlink-'))); + roots.push(root); + // Git reports index paths from the monorepo root; the generated hook runs from this package. + // A violation proves the staged runner re-addresses those paths before it calls Electron ESLint. + const worktree = join(root, 'packages', 'desktop'); + mkdirSync(join(worktree, 'src'), { recursive: true }); + symlinkSync(join(DEVKIT_ROOT, 'node_modules'), join(worktree, 'node_modules')); + writeElectronConfig(worktree, "{ name: 'src', children: [{ name: 'allowed.ts' }] }"); writeFileSync(join(worktree, 'src', 'wrong.ts'), 'export {};\n'); + initializeGit(root); + stage( + root, + 'packages/desktop/package.json', + 'packages/desktop/guard.config.json', + 'packages/desktop/eslint.config.mjs', + 'packages/desktop/src/wrong.ts', + ); - const [command, ...args] = structureCmdFor('electron').split(' '); - const result = spawnSync(command, args, { cwd: worktree, encoding: 'utf8' }); + const result = stagedGate(worktree); - expect(structureCmdFor('electron')).toContain('--preserve-symlinks'); + expect(structureCmdFor('electron')).toBe('guard-structure staged'); expect(result.status, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(1); expect(`${result.stdout}\n${result.stderr}`).toContain('wrong.ts'); }); + + it('probes a staged deletion so a missing required index still blocks', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'electron-structure-deletion-'))); + roots.push(root); + const worktree = join(root, 'worktree'); + mkdirSync(join(worktree, 'src', 'Feature'), { recursive: true }); + symlinkSync(join(DEVKIT_ROOT, 'node_modules'), join(worktree, 'node_modules')); + writeElectronConfig( + worktree, + "{ name: 'src', children: [{ name: 'Feature', enforceExistence: 'index.ts', children: [{ name: 'index.ts' }, { name: 'constants.ts' }] }] }", + ); + writeFileSync(join(worktree, 'src', 'Feature', 'constants.ts'), 'export {};\n'); + writeFileSync(join(worktree, 'src', 'Feature', 'index.ts'), 'export {};\n'); + initializeGit(worktree); + stage( + worktree, + 'package.json', + 'guard.config.json', + 'eslint.config.mjs', + 'src/Feature/index.ts', + 'src/Feature/constants.ts', + ); + const initial = spawnSync('git', ['commit', '-m', 'initial'], { + cwd: worktree, + encoding: 'utf8', + }); + expect(initial.status, initial.stderr).toBe(0); + + rmSync(join(worktree, 'src', 'Feature', 'index.ts')); + const deleted = spawnSync('git', ['add', '-u', '--', 'src/Feature/index.ts'], { + cwd: worktree, + encoding: 'utf8', + }); + expect(deleted.status, deleted.stderr).toBe(0); + + const result = stagedGate(worktree); + expect(result.status, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toMatch( + /Feature[\s\S]*index\.ts|index\.ts[\s\S]*Feature/, + ); + }); }); diff --git a/cli/__tests__/init-doctor.test.mts b/cli/__tests__/init-doctor.test.mts index 7144229c..765bd262 100644 --- a/cli/__tests__/init-doctor.test.mts +++ b/cli/__tests__/init-doctor.test.mts @@ -289,8 +289,8 @@ describe('init --stack react-app (structure ungated)', () => { }); devkit(root, 'init', '--stack', 'react-app', '--yes'); const hook = readFileSync(join(root, '.husky/pre-commit'), 'utf8'); - // config-driven stack → devkit's guard-structure bin, joined to the deterministic orchestrator. - expect(hook).toContain('--structure "guard-structure gate"'); + // Devkit's staged runner is joined to the deterministic orchestrator. + expect(hook).toContain('--structure "guard-structure staged"'); }); }); @@ -319,8 +319,8 @@ describe('init — zero consumer deps (config-driven structure)', () => { ); expect(pkg.scripts['lint:structure']).toBeUndefined(); const hook = readFileSync(join(root, '.husky/pre-commit'), 'utf8'); - // guard-structure runs as the orchestrator's structure gate (trichotomy: exit 2 stays fail-open). - expect(hook).toContain('--structure "guard-structure gate"'); + // guard-structure runs as the staged structure gate (trichotomy: exit 2 stays fail-open). + expect(hook).toContain('--structure "guard-structure staged"'); expect(hook).not.toContain('bunx eslint src'); }); @@ -356,10 +356,7 @@ describe('init — zero consumer deps (config-driven structure)', () => { expect(pkg.devDependencies.eslint).toBeDefined(); expect(pkg.devDependencies['@typescript-eslint/parser']).toBeDefined(); const hook = readFileSync(join(root, '.husky/pre-commit'), 'utf8'); - expect(hook).toContain( - '--structure "node --preserve-symlinks node_modules/eslint/bin/eslint.js src"', - ); - expect(hook).not.toContain('guard-structure'); + expect(hook).toContain('--structure "guard-structure staged"'); }); }); @@ -475,7 +472,7 @@ describe('doctor — selection-aware', () => { const r = devkit(root, 'doctor'); expect(r.status).toBe(0); expect(r.stdout).toMatch(/biome\.jsonc: OK — extends @norvalbv\/devkit\/biome\/react/); - expect(r.stdout).toMatch(/structure-lint: OK — runs `guard-structure gate`/); + expect(r.stdout).toMatch(/structure-lint: OK — runs `guard-structure staged`/); }); it('flags DRIFT when the structure-lint line is missing from the hook', () => { @@ -491,7 +488,7 @@ describe('doctor — selection-aware', () => { const hookPath = join(root, '.husky/pre-commit'); writeFileSync( hookPath, - readFileSync(hookPath, 'utf8').replace(' --structure "guard-structure gate"', ''), + readFileSync(hookPath, 'utf8').replace(' --structure "guard-structure staged"', ''), ); const r = devkit(root, 'doctor'); expect(r.status).toBe(1); diff --git a/cli/__tests__/monorepo.test.mts b/cli/__tests__/monorepo.test.mts index dcdb01a9..bbb21577 100644 --- a/cli/__tests__/monorepo.test.mts +++ b/cli/__tests__/monorepo.test.mts @@ -63,7 +63,7 @@ describe('monorepo: init in a package subdir', () => { expect(hook).toContain('# >>> devkit-guards: services/webapp >>>'); expect(hook).toContain('cd "services/webapp"'); expect(hook).toContain(') || exit 1'); - expect(hook).toContain('--structure "guard-structure gate"'); // config-driven stack → devkit's guard-structure bin (no consumer eslint dep), run as the orchestrator's structure gate + expect(hook).toContain('--structure "guard-structure staged"'); // config-driven stack → Devkit's staged structure runner // skills are repo-wide → at the git root, not the package expect(existsSync(join(root, '.devkit', 'skills-manifest.json'))).toBe(true); diff --git a/cli/__tests__/standalone.test.mts b/cli/__tests__/standalone.test.mts index b491b38e..6055f0d9 100644 --- a/cli/__tests__/standalone.test.mts +++ b/cli/__tests__/standalone.test.mts @@ -61,7 +61,7 @@ describe('standalone (no-package) install', () => { // hook: fail-open GLOBAL gates (no bunx / node_modules), valid POSIX sh const hook = readFileSync(join(root, '.husky/pre-commit'), 'utf8'); expect(hook).toContain('command -v guard-deterministic'); // fail-open global orchestrator - expect(hook).toContain('--structure "guard-structure gate"'); // config-driven structure via the bin + expect(hook).toContain('--structure "guard-structure staged"'); // config-driven structure via Devkit's staged runner expect(hook).not.toContain('bunx guard'); // gates call global bins, not bunx/node_modules expect(() => execFileSync('sh', ['-n', join(root, '.husky/pre-commit')], { stdio: 'pipe' }), @@ -140,7 +140,7 @@ describe('standalone (no-package) install', () => { // Structure-lint runs via the global guard-structure bin (devkit's own eslint/plugin), joined to // the deterministic orchestrator with --structure — fail-open (the orchestrator is command -v-guarded). const hook = readFileSync(join(root, '.husky/pre-commit'), 'utf8'); - expect(hook).toContain('--structure "guard-structure gate"'); + expect(hook).toContain('--structure "guard-structure staged"'); // The STACK guard.config (with the `structure` grammar) is vendored, not the generic one. const structure = JSON.parse(readFileSync(join(root, 'guard.config.json'), 'utf8')).structure; diff --git a/cli/__tests__/upgrade.test.mts b/cli/__tests__/upgrade.test.mts index c69f3f5d..48ad8056 100644 --- a/cli/__tests__/upgrade.test.mts +++ b/cli/__tests__/upgrade.test.mts @@ -169,6 +169,24 @@ describe('devkit upgrade — full reconcile (component-lib repro)', () => { expect(existsSync(join(root, 'eslint.config.mjs'))).toBe(false); // still off — not newly added expect(readFileSync(join(root, '.husky/pre-commit'), 'utf8')).not.toContain('guard-structure'); }); + + it('migrates an enabled Electron structure hook to the Devkit staged runner', () => { + const root = tmpRepo({ ...CLIB_PKG, devDependencies: { electron: '^30' } }); + expect(run(root, 'init', '--stack', 'electron', '--yes', '--no-cursor').status).toBe(0); + const hookPath = join(root, '.husky', 'pre-commit'); + writeFileSync( + hookPath, + readFileSync(hookPath, 'utf8').replace( + 'guard-structure staged', + 'node --preserve-symlinks node_modules/eslint/bin/eslint.js src', + ), + ); + + const up = run(root, 'upgrade'); + expect(up.status, up.stderr || up.stdout).toBe(0); + expect(readFileSync(hookPath, 'utf8')).toContain('guard-structure staged'); + expect(config(root).components.structure).toBe(true); + }); }); describe('devkit upgrade — preflight', () => { diff --git a/cli/commands/doctor.mts b/cli/commands/doctor.mts index 07f1607a..6eab2d8e 100644 --- a/cli/commands/doctor.mts +++ b/cli/commands/doctor.mts @@ -58,11 +58,6 @@ function checkConfig(cwd: string): CheckResult { return check('.devkit/config.json', 'OK', 'present'); } -// Structure-lint check (only when `structure` is selected). `structure` is NOT a guard, so -// checkHusky never verifies it. Structure joins the deterministic orchestrator via a `--structure -// ""` arg on the `guard-deterministic` line: config-driven stacks run devkit's own -// `guard-structure gate` (no consumer eslint dep); electron keeps its consumer-side ESLint command. -// Match that exact arg — its absence means structure-lint is not wired. function checkStructureLint(cwd: string, stack: string): CheckResult { const { gitRoot, pkgRel } = detectGitRoot(cwd); const hookPath = join(gitRoot, '.husky', 'pre-commit'); diff --git a/cli/commands/init.mts b/cli/commands/init.mts index 387addb0..7874bdad 100644 --- a/cli/commands/init.mts +++ b/cli/commands/init.mts @@ -836,7 +836,7 @@ export async function applyInit(cwd: string, plan: InitPlan) { } = plan; // Structure-lint: config-driven stacks (react-app, component-lib) run via devkit's own eslint (the // `guard-structure` bin), so they work even in standalone (no consumer eslint/plugin). Electron's - // preset needs consumer-side eslint/parser/plugin, so it stays package-only. + // preset keeps its pinned local ESLint/plugin, but the Devkit-owned staged runner invokes it. const isStructure = selection.structure && STRUCTURE_STACKS.has(stack) && diff --git a/cli/lib/components.mts b/cli/lib/components.mts index 881770a5..b4217713 100644 --- a/cli/lib/components.mts +++ b/cli/lib/components.mts @@ -71,12 +71,8 @@ export function normalizeReviewProfile( 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 { - if (CONFIG_DRIVEN_STRUCTURE.has(stack)) return 'guard-structure gate'; - // The Electron preset's plugin derives its project root from its own __filename. Ship symlinks - // node_modules into an ephemeral worktree, so preserve that logical path or the plugin silently - // roots itself in the source checkout and skips every worktree file. - return 'node --preserve-symlinks node_modules/eslint/bin/eslint.js src'; +export function structureCmdFor(_stack: string): string { + return 'guard-structure staged'; } /** diff --git a/cli/lib/husky/husky-block.mts b/cli/lib/husky/husky-block.mts index 9f281fd7..45991674 100644 --- a/cli/lib/husky/husky-block.mts +++ b/cli/lib/husky/husky-block.mts @@ -40,8 +40,8 @@ interface HookSelection { // prefix-cache check/record, runs the selected guards (.devkit/config.json components.guards), // applies the rc trichotomy per gate, and aggregates every failure into one report + one exit // code — the hook just propagates it. `--structure ""` joins the stack-resolved structure -// lint to the same aggregated set (config-driven stacks: `guard-structure gate`; electron: its -// consumer-side ESLint command). The old hand-rolled DK_PREFIX_SKIP/DK_DET_FAILS shell protocol is gone. +// lint to the same aggregated set through Devkit's `guard-structure staged` runner. The old +// hand-rolled DK_PREFIX_SKIP/DK_DET_FAILS shell protocol is gone. const deterministicFragment = ( structureCmd?: string, extras: Array<{ label: string; cmd: string }> = [], diff --git a/dist/cli/commands/doctor.mjs b/dist/cli/commands/doctor.mjs index bd979b5f..3466b642 100644 --- a/dist/cli/commands/doctor.mjs +++ b/dist/cli/commands/doctor.mjs @@ -31,11 +31,6 @@ function checkConfig(cwd) { } return check('.devkit/config.json', 'OK', 'present'); } -// Structure-lint check (only when `structure` is selected). `structure` is NOT a guard, so -// checkHusky never verifies it. Structure joins the deterministic orchestrator via a `--structure -// ""` arg on the `guard-deterministic` line: config-driven stacks run devkit's own -// `guard-structure gate` (no consumer eslint dep); electron keeps its consumer-side ESLint command. -// Match that exact arg — its absence means structure-lint is not wired. function checkStructureLint(cwd, stack) { const { gitRoot, pkgRel } = detectGitRoot(cwd); const hookPath = join(gitRoot, '.husky', 'pre-commit'); diff --git a/dist/cli/commands/init.mjs b/dist/cli/commands/init.mjs index a400a946..2433cb03 100644 --- a/dist/cli/commands/init.mjs +++ b/dist/cli/commands/init.mjs @@ -671,7 +671,7 @@ export async function applyInit(cwd, plan) { const { stack, selection, remove = [], force = false, dryRun = false, interactive = false, scanRoots = null, standalone = false, overlay = false, selfHost = false, regenStructureBaselines = true, undecided = [], } = plan; // Structure-lint: config-driven stacks (react-app, component-lib) run via devkit's own eslint (the // `guard-structure` bin), so they work even in standalone (no consumer eslint/plugin). Electron's - // preset needs consumer-side eslint/parser/plugin, so it stays package-only. + // preset keeps its pinned local ESLint/plugin, but the Devkit-owned staged runner invokes it. const isStructure = selection.structure && STRUCTURE_STACKS.has(stack) && (!standalone || CONFIG_DRIVEN_STRUCTURE.has(stack)); diff --git a/dist/cli/lib/components.mjs b/dist/cli/lib/components.mjs index c41c87c4..15d5d6a2 100644 --- a/dist/cli/lib/components.mjs +++ b/dist/cli/lib/components.mjs @@ -43,13 +43,8 @@ export function normalizeReviewProfile(partial, installedGuards, { enabledDefaul /** 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) { - if (CONFIG_DRIVEN_STRUCTURE.has(stack)) - return 'guard-structure gate'; - // The Electron preset's plugin derives its project root from its own __filename. Ship symlinks - // node_modules into an ephemeral worktree, so preserve that logical path or the plugin silently - // roots itself in the source checkout and skips every worktree file. - return 'node --preserve-symlinks node_modules/eslint/bin/eslint.js src'; +export function structureCmdFor(_stack) { + return 'guard-structure staged'; } /** * Compatibility name for the agent surfaces the current projection layer can sync into. Provider diff --git a/dist/cli/lib/husky/husky-block.mjs b/dist/cli/lib/husky/husky-block.mjs index e34616bb..3d772909 100644 --- a/dist/cli/lib/husky/husky-block.mjs +++ b/dist/cli/lib/husky/husky-block.mjs @@ -17,8 +17,8 @@ import { DK_HOOK_HELPERS, DK_REVIEW_BASELINE_HELPER, selectedFragment, } from ". // prefix-cache check/record, runs the selected guards (.devkit/config.json components.guards), // applies the rc trichotomy per gate, and aggregates every failure into one report + one exit // code — the hook just propagates it. `--structure ""` joins the stack-resolved structure -// lint to the same aggregated set (config-driven stacks: `guard-structure gate`; electron: its -// consumer-side ESLint command). The old hand-rolled DK_PREFIX_SKIP/DK_DET_FAILS shell protocol is gone. +// lint to the same aggregated set through Devkit's `guard-structure staged` runner. The old +// hand-rolled DK_PREFIX_SKIP/DK_DET_FAILS shell protocol is gone. const deterministicFragment = (structureCmd, extras = []) => `# devkit:deterministic echo "🚧 Deterministic gates (aggregated)..." __dk_no_git_env bunx guard-deterministic --hook "\${DK_HOOK_PATH:-$0}"${structureCmd ? ` --structure "${structureCmd}"` : ''}${extras.map((e) => ` --extra "${e.label}=${e.cmd}"`).join('')} || exit 1 diff --git a/dist/gate-engine/structure/run.mjs b/dist/gate-engine/structure/run.mjs index 0bd58d13..718ab68c 100644 --- a/dist/gate-engine/structure/run.mjs +++ b/dist/gate-engine/structure/run.mjs @@ -5,7 +5,8 @@ // guard.config.json `structure` block + baselines and returns a runnable eslint flat-config that // embeds the plugin as a LOADED OBJECT — so ESLint never resolves the plugin from the consumer. // -// guard-structure gate # lint the declared structure roots (pre-commit); default subcommand +// guard-structure gate # lint all declared structure roots (CI/manual) +// guard-structure staged # lint only staged structure input (generated pre-commit hook) // // PARAMETERIZED (W-3): the trees / roots / grammar / baselines all come from resolveGuardConfig(cwd) // — the consumer's guard.config.json under the consumer cwd, never the package dir. Grandfathering is @@ -14,8 +15,9 @@ // // Exit contract (the shared gate trichotomy guard-deterministic applies): 0 clean, 1 violations, // 2 fail-open (could-not-run). -import { existsSync, realpathSync } from 'node:fs'; -import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, realpathSync } from 'node:fs'; +import { extname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ESLint } from 'eslint'; // devkit's OWN eslint (now a dependency), never the consumer's import { resolveGuardConfig } from "../config.mjs"; @@ -23,19 +25,196 @@ import { buildStructureConfigs } from "./eslint-config.mjs"; // ESLint throws "No files matching the pattern" for an absent tree and "…are ignored" when every file // in a present tree is ignored — both mean "nothing to lint" (clean), not a failure. Hoisted (perf). const NOTHING_TO_LINT_RE = /No files matching|are ignored/i; +const ELECTRON_SOURCE_EXTENSIONS = ['ts', 'tsx', 'css']; +const POLICY_PATH_RE = /^(?:eslint\.config\.mjs|guard\.config\.json|eslint\/(?:domains\.mjs|baselines\/))/; +function splitNul(output) { + return output.toString().split('\0').filter(Boolean); +} +function pathInRoot(file, root) { + const cleanRoot = root.replace(/\/+$/, ''); + return Boolean(cleanRoot) && (file === cleanRoot || file.startsWith(`${cleanRoot}/`)); +} +function pathInScope(file, scope) { + return pathInRoot(file, scope.root) && scope.extensions.includes(extname(file).slice(1)); +} +function isPolicyPath(file) { + return POLICY_PATH_RE.test(file); +} +function unique(paths) { + return [...new Set(paths)]; +} +export function planStagedStructureLint(scopes, changed, destructive, unstaged) { + const unstablePolicy = unstaged.some(isPolicyPath); + const stagedPolicy = changed.some(isPolicyPath); + const deferred = []; + const probeScopes = new Map(); + const unstableScopes = new Set(scopes + .filter((scope) => unstaged.some((file) => pathInScope(file, scope))) + .map((scope) => scope.root)); + for (const scope of scopes) { + const hasDestructiveChange = destructive.some((file) => pathInScope(file, scope)); + const hasRelevantInput = stagedPolicy || hasDestructiveChange || changed.some((file) => pathInScope(file, scope)); + if (!hasRelevantInput) + continue; + if (unstablePolicy || unstableScopes.has(scope.root)) { + if (unstablePolicy) + deferred.push('structure policy'); + if (hasDestructiveChange) + deferred.push(scope.root); + continue; + } + if (stagedPolicy || hasDestructiveChange) + probeScopes.set(scope.root, scope); + } + const targets = []; + for (const file of changed) { + const scope = scopes.find((candidate) => pathInScope(file, candidate)); + if (!scope) + continue; + if (unstablePolicy || unstableScopes.has(scope.root)) { + deferred.push(file); + continue; + } + targets.push(file); + } + return { + targets: unique(targets), + probeScopes: [...probeScopes.values()], + deferred: unique(deferred), + }; +} +function gitPaths(cwd, args) { + return splitNul(execFileSync('git', args, { cwd, encoding: 'buffer' })); +} +function untrackedPaths(cwd) { + return gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']); +} +function destructivePaths(cwd) { + const fields = splitNul(execFileSync('git', ['diff', '--cached', '--name-status', '-z', '--diff-filter=DR'], { + cwd, + encoding: 'buffer', + })); + const paths = []; + for (let index = 0; index < fields.length;) { + const status = fields[index++] ?? ''; + if (status.startsWith('R')) { + paths.push(fields[index++] ?? '', fields[index++] ?? ''); + } + else if (status.startsWith('D')) { + paths.push(fields[index++] ?? ''); + } + } + return paths.filter(Boolean); +} +function gitPrefix(cwd) { + return execFileSync('git', ['rev-parse', '--show-prefix'], { cwd, encoding: 'utf8' }).trimEnd(); +} +function toCwdPaths(paths, prefix) { + if (!prefix) + return paths; + return paths.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length)); +} +function stagedScopes(cwd) { + const cfg = resolveGuardConfig(cwd); + const trees = cfg.structure?.trees ?? []; + const configScopes = trees + .filter((tree) => Boolean(tree.root)) + .map((tree) => ({ + root: tree.root, + extensions: tree.sourceExtensions?.length ? tree.sourceExtensions : cfg.sourceExtensions, + })); + return configScopes.length + ? configScopes + : cfg.scanRoots.map((root) => ({ root, extensions: ELECTRON_SOURCE_EXTENSIONS })); +} +function firstProbeFile(cwd, scope, excluded) { + const pending = [scope.root]; + while (pending.length) { + const dir = pending.pop(); + let entries; + try { + entries = readdirSync(join(cwd, dir), { withFileTypes: true }); + } + catch { + continue; + } + for (const entry of entries) { + const relative = `${dir}/${entry.name}`; + if (entry.isDirectory()) + pending.push(relative); + else if (entry.isFile() && pathInScope(relative, scope) && !excluded.has(relative)) { + return relative; + } + } + } + return null; +} +export async function runStagedStructureGate(cwd = process.cwd()) { + try { + const prefix = gitPrefix(cwd); + const changed = toCwdPaths(gitPaths(cwd, ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR']), prefix); + // Name-status preserves both sides of a rename, unlike name-only. Either side can remove a + // required sibling, so either must probe its containing structure root. + const destructive = toCwdPaths(destructivePaths(cwd), prefix); + // Untracked sources also change the tree observed by a topology parser, so they carry the + // same deferral rule as tracked working-tree edits. + const unstaged = unique(toCwdPaths([...gitPaths(cwd, ['diff', '--name-only', '-z']), ...untrackedPaths(cwd)], prefix)); + const plan = planStagedStructureLint(stagedScopes(cwd), changed, destructive, unstaged); + if (plan.deferred.length) { + console.error(`⚠️ Structure lint deferred mixed staged/unstaged input to CI: ${plan.deferred.join(', ')}`); + } + // A probe also reads worktree bytes. Do not select a dirty source as the representative file + // for a deletion/rename check; its result would not describe the staged tree either. + const unstableSources = new Set(unstaged); + const probeTargets = plan.probeScopes + .map((scope) => firstProbeFile(cwd, scope, unstableSources)) + .filter((target) => target !== null); + const unprobedRoots = plan.probeScopes + .filter((scope) => !probeTargets.some((target) => pathInScope(target, scope))) + .map((scope) => scope.root); + if (unprobedRoots.length) { + console.error(`⚠️ Structure deletion probe deferred to CI (no remaining source file): ${unprobedRoots.join(', ')}`); + } + if (!plan.targets.length && !probeTargets.length) + return { code: 0, errorCount: 0 }; + const cfg = resolveGuardConfig(cwd); + const trees = cfg.structure?.trees ?? []; + const configDriven = trees.some((tree) => Boolean(tree.grammar)); + if (configDriven) + return runStructureGate(cwd, unique([...plan.targets, ...probeTargets])); + const eslintBin = join(cwd, 'node_modules', 'eslint', 'bin', 'eslint.js'); + if (!existsSync(eslintBin)) { + return { + code: 1, + errorCount: 0, + text: 'guard-structure: electron structure lint needs the locally pinned eslint binary', + }; + } + try { + execFileSync(process.execPath, ['--preserve-symlinks', eslintBin, '--', ...unique([...plan.targets, ...probeTargets])], { cwd, stdio: 'inherit' }); + return { code: 0, errorCount: 0 }; + } + catch { + return { code: 1, errorCount: 0, text: 'guard-structure: local eslint failed' }; + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { code: 2, errorCount: 0, text: `guard-structure: ${message}` }; + } +} /** * Run the folder-structure gate over a repo's declared structure roots. `cwd` is the consumer root * (holds guard.config.json + eslint/baselines/). Result code: 0 = clean / nothing to lint, * 1 = violations, 2 = fail-open (internal error). */ -export async function runStructureGate(cwd = process.cwd()) { +export async function runStructureGate(cwd = process.cwd(), targets) { try { const cfg = resolveGuardConfig(cwd); // Lint only roots that EXIST on disk (an absent root has nothing to enforce yet). const trees = cfg.structure?.trees ?? []; - const roots = trees - .map((t) => t.root) - .filter((r) => (r ? existsSync(join(cwd, r)) : false)); + const roots = (targets ?? + trees.map((t) => t.root).filter((r) => (r ? existsSync(join(cwd, r)) : false))).filter((target) => existsSync(join(cwd, target))); // Nothing declared / nothing present (generic guard.config, electron-only preset, empty tree). if (!roots.length) return { code: 0, errorCount: 0 }; @@ -74,11 +253,13 @@ export async function runStructureGate(cwd = process.cwd()) { } } export async function runCli(cmd = 'gate') { - if (cmd !== 'gate') { - console.error('usage: guard-structure gate'); + if (cmd !== 'gate' && cmd !== 'staged') { + console.error('usage: guard-structure '); process.exit(2); } - const { code, text } = await runStructureGate(process.cwd()); + const { code, text } = cmd === 'staged' + ? await runStagedStructureGate(process.cwd()) + : await runStructureGate(process.cwd()); if (code === 1) { if (text) console.error(text); diff --git a/docs/decisions/zero-consumer-tool-deps.md b/docs/decisions/zero-consumer-tool-deps.md index a409b82a..09062f8f 100644 --- a/docs/decisions/zero-consumer-tool-deps.md +++ b/docs/decisions/zero-consumer-tool-deps.md @@ -18,3 +18,4 @@ created: 2026-07-01 **Anchored-bet:** [BET] **Scope:** cli/commands/init.mjs,gate-engine/co-occurrence/clone-detector.mjs,gate-engine/structure/**,package.json **Source:** collab +- 2026-08-20 — structure:true now emits the single Devkit-owned guard-structure staged runner. It reads configured roots, validates normal staged files once, probes structural renames/deletions from an existing sibling where possible, and defers mixed index/worktree input to CI rather than declaring it checked. Electron still uses its pinned local ESLint preset behind that runner; config-driven stacks continue through Devkit’s bundled ESLint. diff --git a/gate-engine/structure/__tests__/run.test.mts b/gate-engine/structure/__tests__/run.test.mts index 80417986..239b4f5a 100644 --- a/gate-engine/structure/__tests__/run.test.mts +++ b/gate-engine/structure/__tests__/run.test.mts @@ -11,12 +11,13 @@ * here pin the zero-dependency mechanism + the fail-open / nothing-to-lint contract, which are what * the refactor introduces. */ +import { execFileSync } from 'node:child_process'; import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { runStructureGate } from '../run.mts'; +import { planStagedStructureLint, runStagedStructureGate, runStructureGate } from '../run.mts'; const DEVKIT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -41,6 +42,12 @@ afterEach(() => { roots.length = 0; }); +function initializeGit(root: string) { + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'devkit-test@example.com'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Devkit Test'], { cwd: root }); +} + describe('guard-structure gate — zero consumer deps', () => { it("runs from DEVKIT's own eslint/plugin against a conforming tree — no consumer node_modules", () => { // A real component-lib grammar; the repo has NO node_modules, so this passing at all proves the @@ -150,3 +157,108 @@ describe('guard-structure gate — zero consumer deps', () => { expect((await runStructureGate(root)).code).toBe(0); // 'b' reached + clean, not a masked/fail-open }); }); + +describe('guard-structure staged plan', () => { + const scopes = [ + { root: 'src', extensions: ['ts', 'tsx', 'css'] }, + { root: 'socket-server/src', extensions: ['ts', 'tsx', 'css'] }, + { root: 'vercel-serverless', extensions: ['ts', 'tsx', 'css'] }, + ]; + + it('keeps every configured root and NUL-safe pathname as one ESLint target', () => { + const unusual = 'socket-server/src/with a space/line\nbreak.ts'; + expect( + planStagedStructureLint( + scopes, + ['src/main.ts', unusual, 'vercel-serverless/handler.ts', 'README.md'], + [], + [], + ), + ).toEqual({ + targets: ['src/main.ts', unusual, 'vercel-serverless/handler.ts'], + probeScopes: [], + deferred: [], + }); + }); + + it('probes only the affected root after a deletion or rename', () => { + expect( + planStagedStructureLint( + scopes, + ['src/Feature/Renamed.ts'], + ['src/Feature/index.ts', 'src/Feature/Renamed.ts'], + [], + ), + ).toEqual({ + targets: ['src/Feature/Renamed.ts'], + probeScopes: [{ root: 'src', extensions: ['ts', 'tsx', 'css'] }], + deferred: [], + }); + }); + + it('defers a partially staged source file instead of reading its worktree bytes as index bytes', () => { + expect( + planStagedStructureLint(scopes, ['src/Feature/index.ts'], [], ['src/Feature/index.ts']), + ).toEqual({ targets: [], probeScopes: [], deferred: ['src/Feature/index.ts'] }); + }); + + it('defers every staged file in a topology root with an unrelated working-tree source', () => { + expect( + planStagedStructureLint(scopes, ['src/Feature/index.ts'], [], ['src/Feature/Uncommitted.ts']), + ).toEqual({ targets: [], probeScopes: [], deferred: ['src/Feature/index.ts'] }); + }); + + it('defers all staged structure input when its policy has unstaged edits', () => { + expect( + planStagedStructureLint(scopes, ['src/Feature/index.ts'], [], ['eslint.config.mjs']), + ).toEqual({ + targets: [], + probeScopes: [], + deferred: ['structure policy', 'src/Feature/index.ts'], + }); + }); +}); + +describe('guard-structure staged execution', () => { + const config = { + scanRoots: ['src'], + sourceExtensions: ['ts'], + structure: { + trees: [ + { + name: 'lib', + root: 'src', + sourceExtensions: ['ts'], + grammar: { files: ['{pascal}'] }, + }, + ], + }, + }; + + it('checks a config-driven staged file from a package subdirectory, not sibling packages', async () => { + const root = repo(); + const pkg = join(root, 'packages', 'lib'); + write(pkg, 'guard.config.json', JSON.stringify(config)); + write(pkg, 'src/Ok.ts'); + write(root, 'packages/other/src/Wrong.ts'); + initializeGit(root); + execFileSync('git', ['add', '--', 'packages/lib/guard.config.json', 'packages/lib/src/Ok.ts'], { + cwd: root, + }); + + await expect(runStagedStructureGate(pkg)).resolves.toMatchObject({ code: 0 }); + }); + + it('does not treat unstaged worktree bytes as a verdict on a staged file', async () => { + const root = repo(); + write(root, 'guard.config.json', JSON.stringify(config)); + write(root, 'src/Ok.ts'); + initializeGit(root); + execFileSync('git', ['add', '--', 'guard.config.json', 'src/Ok.ts'], { cwd: root }); + // The index is valid. The working tree is not. The pre-commit runner must defer this file to + // CI rather than reject a staged snapshot on the basis of an unstaged edit. + write(root, 'src/not-ok.ts'); + + await expect(runStagedStructureGate(root)).resolves.toMatchObject({ code: 0 }); + }); +}); diff --git a/gate-engine/structure/run.mts b/gate-engine/structure/run.mts index 634aa983..680c39db 100755 --- a/gate-engine/structure/run.mts +++ b/gate-engine/structure/run.mts @@ -5,7 +5,8 @@ // guard.config.json `structure` block + baselines and returns a runnable eslint flat-config that // embeds the plugin as a LOADED OBJECT — so ESLint never resolves the plugin from the consumer. // -// guard-structure gate # lint the declared structure roots (pre-commit); default subcommand +// guard-structure gate # lint all declared structure roots (CI/manual) +// guard-structure staged # lint only staged structure input (generated pre-commit hook) // // PARAMETERIZED (W-3): the trees / roots / grammar / baselines all come from resolveGuardConfig(cwd) // — the consumer's guard.config.json under the consumer cwd, never the package dir. Grandfathering is @@ -15,8 +16,9 @@ // Exit contract (the shared gate trichotomy guard-deterministic applies): 0 clean, 1 violations, // 2 fail-open (could-not-run). -import { existsSync, realpathSync } from 'node:fs'; -import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, realpathSync } from 'node:fs'; +import { extname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ESLint } from 'eslint'; // devkit's OWN eslint (now a dependency), never the consumer's import { resolveGuardConfig } from '../config.mts'; @@ -25,6 +27,19 @@ import { buildStructureConfigs } from './eslint-config.mts'; // The one field this gate reads off each structure.trees[] entry — its on-disk root. interface StructureTree { root?: string; + sourceExtensions?: string[]; + grammar?: unknown; +} + +interface StagedScope { + root: string; + extensions: string[]; +} + +interface StagedPlan { + targets: string[]; + probeScopes: StagedScope[]; + deferred: string[]; } // Outcome of the folder-structure gate: 0 clean / nothing to lint, 1 violations, 2 fail-open. @@ -37,20 +52,235 @@ interface StructureGateResult { // ESLint throws "No files matching the pattern" for an absent tree and "…are ignored" when every file // in a present tree is ignored — both mean "nothing to lint" (clean), not a failure. Hoisted (perf). const NOTHING_TO_LINT_RE = /No files matching|are ignored/i; +const ELECTRON_SOURCE_EXTENSIONS = ['ts', 'tsx', 'css']; +const POLICY_PATH_RE = + /^(?:eslint\.config\.mjs|guard\.config\.json|eslint\/(?:domains\.mjs|baselines\/))/; + +function splitNul(output: string | Buffer): string[] { + return output.toString().split('\0').filter(Boolean); +} + +function pathInRoot(file: string, root: string): boolean { + const cleanRoot = root.replace(/\/+$/, ''); + return Boolean(cleanRoot) && (file === cleanRoot || file.startsWith(`${cleanRoot}/`)); +} + +function pathInScope(file: string, scope: StagedScope): boolean { + return pathInRoot(file, scope.root) && scope.extensions.includes(extname(file).slice(1)); +} + +function isPolicyPath(file: string): boolean { + return POLICY_PATH_RE.test(file); +} + +function unique(paths: string[]): string[] { + return [...new Set(paths)]; +} + +export function planStagedStructureLint( + scopes: StagedScope[], + changed: string[], + destructive: string[], + unstaged: string[], +): StagedPlan { + const unstablePolicy = unstaged.some(isPolicyPath); + const stagedPolicy = changed.some(isPolicyPath); + const deferred: string[] = []; + const probeScopes = new Map(); + const unstableScopes = new Set( + scopes + .filter((scope) => unstaged.some((file) => pathInScope(file, scope))) + .map((scope) => scope.root), + ); + + for (const scope of scopes) { + const hasDestructiveChange = destructive.some((file) => pathInScope(file, scope)); + const hasRelevantInput = + stagedPolicy || hasDestructiveChange || changed.some((file) => pathInScope(file, scope)); + if (!hasRelevantInput) continue; + if (unstablePolicy || unstableScopes.has(scope.root)) { + if (unstablePolicy) deferred.push('structure policy'); + if (hasDestructiveChange) deferred.push(scope.root); + continue; + } + if (stagedPolicy || hasDestructiveChange) probeScopes.set(scope.root, scope); + } + + const targets: string[] = []; + for (const file of changed) { + const scope = scopes.find((candidate) => pathInScope(file, candidate)); + if (!scope) continue; + if (unstablePolicy || unstableScopes.has(scope.root)) { + deferred.push(file); + continue; + } + targets.push(file); + } + return { + targets: unique(targets), + probeScopes: [...probeScopes.values()], + deferred: unique(deferred), + }; +} + +function gitPaths(cwd: string, args: string[]): string[] { + return splitNul(execFileSync('git', args, { cwd, encoding: 'buffer' })); +} + +function untrackedPaths(cwd: string): string[] { + return gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']); +} + +function destructivePaths(cwd: string): string[] { + const fields = splitNul( + execFileSync('git', ['diff', '--cached', '--name-status', '-z', '--diff-filter=DR'], { + cwd, + encoding: 'buffer', + }), + ); + const paths: string[] = []; + for (let index = 0; index < fields.length;) { + const status = fields[index++] ?? ''; + if (status.startsWith('R')) { + paths.push(fields[index++] ?? '', fields[index++] ?? ''); + } else if (status.startsWith('D')) { + paths.push(fields[index++] ?? ''); + } + } + return paths.filter(Boolean); +} + +function gitPrefix(cwd: string): string { + return execFileSync('git', ['rev-parse', '--show-prefix'], { cwd, encoding: 'utf8' }).trimEnd(); +} + +function toCwdPaths(paths: string[], prefix: string): string[] { + if (!prefix) return paths; + return paths.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length)); +} + +function stagedScopes(cwd: string): StagedScope[] { + const cfg = resolveGuardConfig(cwd); + const trees: StructureTree[] = cfg.structure?.trees ?? []; + const configScopes = trees + .filter((tree): tree is StructureTree & { root: string } => Boolean(tree.root)) + .map((tree) => ({ + root: tree.root, + extensions: tree.sourceExtensions?.length ? tree.sourceExtensions : cfg.sourceExtensions, + })); + return configScopes.length + ? configScopes + : cfg.scanRoots.map((root) => ({ root, extensions: ELECTRON_SOURCE_EXTENSIONS })); +} + +function firstProbeFile( + cwd: string, + scope: StagedScope, + excluded: ReadonlySet, +): string | null { + const pending = [scope.root]; + while (pending.length) { + const dir = pending.pop()!; + let entries; + try { + entries = readdirSync(join(cwd, dir), { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const relative = `${dir}/${entry.name}`; + if (entry.isDirectory()) pending.push(relative); + else if (entry.isFile() && pathInScope(relative, scope) && !excluded.has(relative)) { + return relative; + } + } + } + return null; +} + +export async function runStagedStructureGate(cwd = process.cwd()): Promise { + try { + const prefix = gitPrefix(cwd); + const changed = toCwdPaths( + gitPaths(cwd, ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR']), + prefix, + ); + // Name-status preserves both sides of a rename, unlike name-only. Either side can remove a + // required sibling, so either must probe its containing structure root. + const destructive = toCwdPaths(destructivePaths(cwd), prefix); + // Untracked sources also change the tree observed by a topology parser, so they carry the + // same deferral rule as tracked working-tree edits. + const unstaged = unique( + toCwdPaths([...gitPaths(cwd, ['diff', '--name-only', '-z']), ...untrackedPaths(cwd)], prefix), + ); + const plan = planStagedStructureLint(stagedScopes(cwd), changed, destructive, unstaged); + if (plan.deferred.length) { + console.error( + `⚠️ Structure lint deferred mixed staged/unstaged input to CI: ${plan.deferred.join(', ')}`, + ); + } + // A probe also reads worktree bytes. Do not select a dirty source as the representative file + // for a deletion/rename check; its result would not describe the staged tree either. + const unstableSources = new Set(unstaged); + const probeTargets = plan.probeScopes + .map((scope) => firstProbeFile(cwd, scope, unstableSources)) + .filter((target): target is string => target !== null); + const unprobedRoots = plan.probeScopes + .filter((scope) => !probeTargets.some((target) => pathInScope(target, scope))) + .map((scope) => scope.root); + if (unprobedRoots.length) { + console.error( + `⚠️ Structure deletion probe deferred to CI (no remaining source file): ${unprobedRoots.join(', ')}`, + ); + } + if (!plan.targets.length && !probeTargets.length) return { code: 0, errorCount: 0 }; + + const cfg = resolveGuardConfig(cwd); + const trees: StructureTree[] = cfg.structure?.trees ?? []; + const configDriven = trees.some((tree) => Boolean(tree.grammar)); + if (configDriven) return runStructureGate(cwd, unique([...plan.targets, ...probeTargets])); + + const eslintBin = join(cwd, 'node_modules', 'eslint', 'bin', 'eslint.js'); + if (!existsSync(eslintBin)) { + return { + code: 1, + errorCount: 0, + text: 'guard-structure: electron structure lint needs the locally pinned eslint binary', + }; + } + try { + execFileSync( + process.execPath, + ['--preserve-symlinks', eslintBin, '--', ...unique([...plan.targets, ...probeTargets])], + { cwd, stdio: 'inherit' }, + ); + return { code: 0, errorCount: 0 }; + } catch { + return { code: 1, errorCount: 0, text: 'guard-structure: local eslint failed' }; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { code: 2, errorCount: 0, text: `guard-structure: ${message}` }; + } +} /** * Run the folder-structure gate over a repo's declared structure roots. `cwd` is the consumer root * (holds guard.config.json + eslint/baselines/). Result code: 0 = clean / nothing to lint, * 1 = violations, 2 = fail-open (internal error). */ -export async function runStructureGate(cwd = process.cwd()): Promise { +export async function runStructureGate( + cwd = process.cwd(), + targets?: string[], +): Promise { try { const cfg = resolveGuardConfig(cwd); // Lint only roots that EXIST on disk (an absent root has nothing to enforce yet). const trees: StructureTree[] = cfg.structure?.trees ?? []; - const roots = trees - .map((t) => t.root) - .filter((r): r is string => (r ? existsSync(join(cwd, r)) : false)); + const roots = ( + targets ?? + trees.map((t) => t.root).filter((r): r is string => (r ? existsSync(join(cwd, r)) : false)) + ).filter((target) => existsSync(join(cwd, target))); // Nothing declared / nothing present (generic guard.config, electron-only preset, empty tree). if (!roots.length) return { code: 0, errorCount: 0 }; const baseConfig = await buildStructureConfigs(cwd); @@ -85,11 +315,14 @@ export async function runStructureGate(cwd = process.cwd()): Promise'); process.exit(2); } - const { code, text } = await runStructureGate(process.cwd()); + const { code, text } = + cmd === 'staged' + ? await runStagedStructureGate(process.cwd()) + : await runStructureGate(process.cwd()); if (code === 1) { if (text) console.error(text); console.error( From 286d666aab2eff57420a9f171ac5ab807c943df2 Mon Sep 17 00:00:00 2001 From: norvalbv Date: Thu, 20 Aug 2026 17:26:37 +0100 Subject: [PATCH 2/2] Address staged structure review findings Address the repository-root and nested untracked path cases reported in review. --- dist/gate-engine/ratchets/git-index.mjs | 2 +- dist/gate-engine/structure/run.mjs | 15 ++++---- gate-engine/ratchets/git-index.mts | 2 +- gate-engine/structure/__tests__/run.test.mts | 38 +++++++++++++++++++- gate-engine/structure/run.mts | 16 +++------ 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/dist/gate-engine/ratchets/git-index.mjs b/dist/gate-engine/ratchets/git-index.mjs index b2312c58..1e8f0e96 100644 --- a/dist/gate-engine/ratchets/git-index.mjs +++ b/dist/gate-engine/ratchets/git-index.mjs @@ -41,7 +41,7 @@ export function hasStagedFiles(root) { } // Split a NUL-delimited git list. `-z` is used so a path containing a newline (or one git would // otherwise quote and escape) survives verbatim. -function splitNul(out) { +export function splitNul(out) { return out.split('\0').filter((line) => line.length > 0); } // The repo-root-relative paths ADDED/COPIED/MODIFIED/RENAMED in the pending commit (the git index). diff --git a/dist/gate-engine/structure/run.mjs b/dist/gate-engine/structure/run.mjs index 718ab68c..92929578 100644 --- a/dist/gate-engine/structure/run.mjs +++ b/dist/gate-engine/structure/run.mjs @@ -21,17 +21,17 @@ import { extname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ESLint } from 'eslint'; // devkit's OWN eslint (now a dependency), never the consumer's import { resolveGuardConfig } from "../config.mjs"; +import { gitPrefix, splitNul } from "../ratchets/git-index.mjs"; import { buildStructureConfigs } from "./eslint-config.mjs"; // ESLint throws "No files matching the pattern" for an absent tree and "…are ignored" when every file // in a present tree is ignored — both mean "nothing to lint" (clean), not a failure. Hoisted (perf). const NOTHING_TO_LINT_RE = /No files matching|are ignored/i; const ELECTRON_SOURCE_EXTENSIONS = ['ts', 'tsx', 'css']; const POLICY_PATH_RE = /^(?:eslint\.config\.mjs|guard\.config\.json|eslint\/(?:domains\.mjs|baselines\/))/; -function splitNul(output) { - return output.toString().split('\0').filter(Boolean); -} function pathInRoot(file, root) { const cleanRoot = root.replace(/\/+$/, ''); + if (cleanRoot === '.') + return true; return Boolean(cleanRoot) && (file === cleanRoot || file.startsWith(`${cleanRoot}/`)); } function pathInScope(file, scope) { @@ -84,16 +84,16 @@ export function planStagedStructureLint(scopes, changed, destructive, unstaged) }; } function gitPaths(cwd, args) { - return splitNul(execFileSync('git', args, { cwd, encoding: 'buffer' })); + return splitNul(execFileSync('git', args, { cwd, encoding: 'buffer' }).toString()); } function untrackedPaths(cwd) { - return gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']); + return gitPaths(cwd, ['ls-files', '--full-name', '--others', '--exclude-standard', '-z']); } function destructivePaths(cwd) { const fields = splitNul(execFileSync('git', ['diff', '--cached', '--name-status', '-z', '--diff-filter=DR'], { cwd, encoding: 'buffer', - })); + }).toString()); const paths = []; for (let index = 0; index < fields.length;) { const status = fields[index++] ?? ''; @@ -106,9 +106,6 @@ function destructivePaths(cwd) { } return paths.filter(Boolean); } -function gitPrefix(cwd) { - return execFileSync('git', ['rev-parse', '--show-prefix'], { cwd, encoding: 'utf8' }).trimEnd(); -} function toCwdPaths(paths, prefix) { if (!prefix) return paths; diff --git a/gate-engine/ratchets/git-index.mts b/gate-engine/ratchets/git-index.mts index 40771f59..c348b987 100644 --- a/gate-engine/ratchets/git-index.mts +++ b/gate-engine/ratchets/git-index.mts @@ -43,7 +43,7 @@ export function hasStagedFiles(root: string): boolean { // Split a NUL-delimited git list. `-z` is used so a path containing a newline (or one git would // otherwise quote and escape) survives verbatim. -function splitNul(out: string): string[] { +export function splitNul(out: string): string[] { return out.split('\0').filter((line) => line.length > 0); } diff --git a/gate-engine/structure/__tests__/run.test.mts b/gate-engine/structure/__tests__/run.test.mts index 239b4f5a..61851759 100644 --- a/gate-engine/structure/__tests__/run.test.mts +++ b/gate-engine/structure/__tests__/run.test.mts @@ -16,7 +16,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { planStagedStructureLint, runStagedStructureGate, runStructureGate } from '../run.mts'; const DEVKIT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -196,6 +196,22 @@ describe('guard-structure staged plan', () => { }); }); + it('plans additions, deletions, and renames for a repository-root tree', () => { + const rootScope = [{ root: '.', extensions: ['ts'] }]; + expect( + planStagedStructureLint( + rootScope, + ['src/Added.ts', 'src/Renamed.ts'], + ['src/Deleted.ts', 'src/Renamed.ts'], + [], + ), + ).toEqual({ + targets: ['src/Added.ts', 'src/Renamed.ts'], + probeScopes: rootScope, + deferred: [], + }); + }); + it('defers a partially staged source file instead of reading its worktree bytes as index bytes', () => { expect( planStagedStructureLint(scopes, ['src/Feature/index.ts'], [], ['src/Feature/index.ts']), @@ -261,4 +277,24 @@ describe('guard-structure staged execution', () => { await expect(runStagedStructureGate(root)).resolves.toMatchObject({ code: 0 }); }); + + it('defers an untracked source from a package cwd', async () => { + const root = repo(); + const pkg = join(root, 'packages', 'lib'); + write(pkg, 'guard.config.json', JSON.stringify(config)); + write(pkg, 'src/Ok.ts'); + initializeGit(root); + execFileSync('git', ['add', '--', 'packages/lib/guard.config.json', 'packages/lib/src/Ok.ts'], { + cwd: root, + }); + execFileSync('git', ['commit', '-qm', 'initial'], { cwd: root }); + write(pkg, 'src/Ok.ts', 'export const changed = true;\n'); + execFileSync('git', ['add', '--', 'packages/lib/src/Ok.ts'], { cwd: root }); + write(pkg, 'src/Uncommitted.ts'); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(runStagedStructureGate(pkg)).resolves.toMatchObject({ code: 0 }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('src/Ok.ts')); + error.mockRestore(); + }); }); diff --git a/gate-engine/structure/run.mts b/gate-engine/structure/run.mts index 680c39db..35619ce6 100755 --- a/gate-engine/structure/run.mts +++ b/gate-engine/structure/run.mts @@ -22,6 +22,7 @@ import { extname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ESLint } from 'eslint'; // devkit's OWN eslint (now a dependency), never the consumer's import { resolveGuardConfig } from '../config.mts'; +import { gitPrefix, splitNul } from '../ratchets/git-index.mts'; import { buildStructureConfigs } from './eslint-config.mts'; // The one field this gate reads off each structure.trees[] entry — its on-disk root. @@ -56,12 +57,9 @@ const ELECTRON_SOURCE_EXTENSIONS = ['ts', 'tsx', 'css']; const POLICY_PATH_RE = /^(?:eslint\.config\.mjs|guard\.config\.json|eslint\/(?:domains\.mjs|baselines\/))/; -function splitNul(output: string | Buffer): string[] { - return output.toString().split('\0').filter(Boolean); -} - function pathInRoot(file: string, root: string): boolean { const cleanRoot = root.replace(/\/+$/, ''); + if (cleanRoot === '.') return true; return Boolean(cleanRoot) && (file === cleanRoot || file.startsWith(`${cleanRoot}/`)); } @@ -124,11 +122,11 @@ export function planStagedStructureLint( } function gitPaths(cwd: string, args: string[]): string[] { - return splitNul(execFileSync('git', args, { cwd, encoding: 'buffer' })); + return splitNul(execFileSync('git', args, { cwd, encoding: 'buffer' }).toString()); } function untrackedPaths(cwd: string): string[] { - return gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']); + return gitPaths(cwd, ['ls-files', '--full-name', '--others', '--exclude-standard', '-z']); } function destructivePaths(cwd: string): string[] { @@ -136,7 +134,7 @@ function destructivePaths(cwd: string): string[] { execFileSync('git', ['diff', '--cached', '--name-status', '-z', '--diff-filter=DR'], { cwd, encoding: 'buffer', - }), + }).toString(), ); const paths: string[] = []; for (let index = 0; index < fields.length;) { @@ -150,10 +148,6 @@ function destructivePaths(cwd: string): string[] { return paths.filter(Boolean); } -function gitPrefix(cwd: string): string { - return execFileSync('git', ['rev-parse', '--show-prefix'], { cwd, encoding: 'utf8' }).trimEnd(); -} - function toCwdPaths(paths: string[], prefix: string): string[] { if (!prefix) return paths; return paths.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length));