Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 89 additions & 12 deletions cli/__tests__/electron-structure-symlink.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 [{
Expand All @@ -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/,
);
});
});
17 changes: 7 additions & 10 deletions cli/__tests__/init-doctor.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
});

Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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"');
});
});

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion cli/__tests__/monorepo.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions cli/__tests__/standalone.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions cli/__tests__/upgrade.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 0 additions & 5 deletions cli/commands/doctor.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<cmd>"` 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');
Expand Down
2 changes: 1 addition & 1 deletion cli/commands/init.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down
8 changes: 2 additions & 6 deletions cli/lib/components.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
4 changes: 2 additions & 2 deletions cli/lib/husky/husky-block.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<cmd>"` 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 }> = [],
Expand Down
5 changes: 0 additions & 5 deletions dist/cli/commands/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<cmd>"` 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');
Expand Down
2 changes: 1 addition & 1 deletion dist/cli/commands/init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 2 additions & 7 deletions dist/cli/lib/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions dist/cli/lib/husky/husky-block.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<cmd>"` 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
Expand Down
2 changes: 1 addition & 1 deletion dist/gate-engine/ratchets/git-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading