diff --git a/cli/commands/ship.mts b/cli/commands/ship.mts index ff379041..22360b27 100644 --- a/cli/commands/ship.mts +++ b/cli/commands/ship.mts @@ -46,6 +46,10 @@ Env: command-rewriting shell hooks (same caveat as SHIP_COMMIT_TIMEOUT). Editing "coverage": false in guard.config.json does NOT work here — ship reads that file from the committed tree, so a local-only edit is silently ignored. + GUARD_STRUCTURE_OK=1 Ship without structure lint, for THIS run only (alias: GUARD_NO_STRUCTURE=1). + Use only when the BASE branch already has structure violations your diff did + not cause. The gate logs a loud BYPASSED line, records telemetry, and keeps + every other deterministic gate active. Prefer exporting it on its own line. Exits 0 on PR opened (or committed under SHIP_DRY_RUN), 1 on any preflight/git/gh error. A commit that lands but fails to push KEEPS the branch (recovery line on stderr); a commit that never lands diff --git a/dist/cli/commands/ship.mjs b/dist/cli/commands/ship.mjs index f066216f..33d4398c 100644 --- a/dist/cli/commands/ship.mjs +++ b/dist/cli/commands/ship.mjs @@ -45,6 +45,10 @@ Env: command-rewriting shell hooks (same caveat as SHIP_COMMIT_TIMEOUT). Editing "coverage": false in guard.config.json does NOT work here — ship reads that file from the committed tree, so a local-only edit is silently ignored. + GUARD_STRUCTURE_OK=1 Ship without structure lint, for THIS run only (alias: GUARD_NO_STRUCTURE=1). + Use only when the BASE branch already has structure violations your diff did + not cause. The gate logs a loud BYPASSED line, records telemetry, and keeps + every other deterministic gate active. Prefer exporting it on its own line. Exits 0 on PR opened (or committed under SHIP_DRY_RUN), 1 on any preflight/git/gh error. A commit that lands but fails to push KEEPS the branch (recovery line on stderr); a commit that never lands diff --git a/dist/gate-engine/config.mjs b/dist/gate-engine/config.mjs index dcebbb56..4af51fb2 100644 --- a/dist/gate-engine/config.mjs +++ b/dist/gate-engine/config.mjs @@ -174,6 +174,18 @@ export function envFlag(name) { export function coverageBypassed() { return envFlag('COVERAGE_OK') || envFlag('NO_COVERAGE'); } +/** + * Is structure lint bypassed for THIS run? The orchestrator owns this predicate rather than + * guard-structure because Electron consumers supply their own arbitrary eslint command through + * `--structure`; putting the bypass inside guard-structure would leave those consumers wedged. + * + * `GUARD_STRUCTURE_OK` is the canonical operator assertion. `GUARD_NO_STRUCTURE` is the accepted + * guessable alias, matching coverageBypassed. guard-deterministic banners + telemeters the skip and + * salts its prefix-cache scope so this one-run assertion cannot authorise a later normal run. + */ +export function structureBypassed() { + return envFlag('STRUCTURE_OK') || envFlag('NO_STRUCTURE'); +} /** * Does THIS run refuse to accept a deterministic gate's fail-open? Lives beside coverageBypassed for * the same reason: guard-deterministic asks it twice — once to decide the verdict, once to salt the diff --git a/dist/gate-engine/deterministic/run.mjs b/dist/gate-engine/deterministic/run.mjs index e6927fff..dfa12c17 100644 --- a/dist/gate-engine/deterministic/run.mjs +++ b/dist/gate-engine/deterministic/run.mjs @@ -41,7 +41,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { coverageBypassed, deterministicStrict } from "../config.mjs"; +import { coverageBypassed, deterministicStrict, structureBypassed } from "../config.mjs"; import { emitGateEvent, finishGateTiming } from "../judge/gate-events.mjs"; import { prefixEntry, recordPrefix } from "../prefix-cache/prefix-cache.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -184,7 +184,15 @@ export function prefixCacheScope(scope, effectiveIds) { // bypassed run cannot reuse a clean run's key either, so it re-runs the other gates. Same trade // the review salt makes — correctness over a cache hit.) `?? 'devkit-guards'` materialises the // default scope checkPrefix would otherwise supply internally, or the salt would be lost. - return coverageBypassed() ? `${strictBase ?? 'devkit-guards'}:coverage-bypassed` : strictBase; + const coverageBase = coverageBypassed() + ? `${strictBase ?? 'devkit-guards'}:coverage-bypassed` + : strictBase; + // Structure commands are arbitrary (`guard-structure`, Electron's eslint invocation, devkit's + // own package script), so the bypass lives at this orchestrator layer. Keep its cache namespace + // apart from a normal run for the same anti-laundering reason as coverage above. + return structureBypassed() + ? `${coverageBase ?? 'devkit-guards'}:structure-bypassed` + : coverageBase; } // 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. @@ -254,14 +262,28 @@ export function runDeterministic(cwd = process.cwd(), opts = {}) { 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 cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope }); - const skip = Boolean(cachedPrefix); - const fails = []; + const cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope }); + const skip = Boolean(cachedPrefix); + const bypassStructure = Boolean(opts.structure) && structureBypassed(); + const fails = []; // Gates that opted out (exit 2 where that IS an opt-out) and so proved nothing. Reported even on a // green run — the whole defect this exists for is a skipped gate reading like a passed one. - const skipped = []; - if (!skip) { - const ids = new Set(effectiveIds); + const skipped = []; + if (!skip) { + if (bypassStructure) { + console.log('⚠️ Structure lint BYPASSED for this run (GUARD_STRUCTURE_OK=1).'); + console.log(' Repository structure was NOT verified for this commit.'); + emitGateEvent({ + type: 'gate_result', + gate: 'structure-lint', + // The collector's gate_result schema accepts fail | could_not_run. Keep the deliberate + // bypass measurable as a non-run, and distinguish it from an infrastructure opt-out in + // detail instead of inventing a status that downstream readers would treat as clean. + status: 'could_not_run', + detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)', + }); + } + const ids = new Set(effectiveIds); const gates = 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], @@ -269,8 +291,9 @@ export function runDeterministic(cwd = process.cwd(), opts = {}) { })); for (const x of opts.extra ?? []) gates.push(commandGate(x.label, x.cmd)); - if (opts.structure) + if (opts.structure && !bypassStructure) { gates.push(commandGate('structure-lint', opts.structure)); + } for (const gate of gates) { if (!gate.argv) { fails.push(`${gate.label}(unrunnable: empty command)`); @@ -327,6 +350,11 @@ export function runDeterministic(cwd = process.cwd(), opts = {}) { } console.error(`✗ deterministic gates failed:${fails.map((f) => ` ${f}`).join('')}`); console.error(' Every deterministic failure is listed above — fix them together, then commit once.'); + if (fails.some((f) => f.startsWith('structure-lint'))) { + console.error(' Base branch structure debt that your diff did not cause? Re-run with the explicit'); + console.error(' one-run assertion: export GUARD_STRUCTURE_OK=1'); + console.error(' A structure violation introduced by your own change must be fixed instead.'); + } if (fails.some((f) => NOT_FOUND_RE.test(f))) { console.error(' exit 127 = command not found: the gate ran, but its BINARY did not resolve — a'); console.error(' dependency problem, not a code finding. Under `devkit ship` the gates run in an'); diff --git a/gate-engine/config.mts b/gate-engine/config.mts index dbf16567..f1557dd2 100644 --- a/gate-engine/config.mts +++ b/gate-engine/config.mts @@ -277,6 +277,19 @@ export function coverageBypassed(): boolean { return envFlag('COVERAGE_OK') || envFlag('NO_COVERAGE'); } +/** + * Is structure lint bypassed for THIS run? The orchestrator owns this predicate rather than + * guard-structure because Electron consumers supply their own arbitrary eslint command through + * `--structure`; putting the bypass inside guard-structure would leave those consumers wedged. + * + * `GUARD_STRUCTURE_OK` is the canonical operator assertion. `GUARD_NO_STRUCTURE` is the accepted + * guessable alias, matching coverageBypassed. guard-deterministic banners + telemeters the skip and + * salts its prefix-cache scope so this one-run assertion cannot authorise a later normal run. + */ +export function structureBypassed(): boolean { + return envFlag('STRUCTURE_OK') || envFlag('NO_STRUCTURE'); +} + /** * Does THIS run refuse to accept a deterministic gate's fail-open? Lives beside coverageBypassed for * the same reason: guard-deterministic asks it twice — once to decide the verdict, once to salt the diff --git a/gate-engine/deterministic/__tests__/run.test.mts b/gate-engine/deterministic/__tests__/run.test.mts index e83fa020..06ce07ca 100644 --- a/gate-engine/deterministic/__tests__/run.test.mts +++ b/gate-engine/deterministic/__tests__/run.test.mts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -15,6 +16,8 @@ afterEach(() => { delete process.env.DEVKIT_SHIP; delete process.env.GUARD_COVERAGE_OK; delete process.env.GUARD_NO_COVERAGE; + delete process.env.GUARD_STRUCTURE_OK; + delete process.env.GUARD_NO_STRUCTURE; // Both spellings: envVar() accepts the FRINK_ alias, and `devkit ship` exports strict envs that a // pre-push vitest inherits — a leak that would silently flip every fail-open assertion below. delete process.env.GUARD_DETERMINISTIC_STRICT; @@ -140,6 +143,86 @@ describe('runDeterministic — --structure / --extra / --only', () => { expect(exec).toHaveBeenCalledWith('bunx', ['eslint', 'src'], expect.anything()); }); + it.each(['GUARD_STRUCTURE_OK', 'GUARD_NO_STRUCTURE'])( + '%s skips an arbitrary structure command but keeps the other deterministic gates active', + (key) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const d = repo(['size']); + const exec = mkExec({}); + process.env[key] = '1'; + + expect(runDeterministic(d, { exec, structure: 'bunx eslint src' })).toBe(0); + expect(exec).toHaveBeenCalledTimes(1); // size still ran; only structure was skipped + expect(exec.mock.calls.some(([bin]) => bin === 'bunx')).toBe(false); + expect(log.mock.calls.flat().join('\n')).toContain('Structure lint BYPASSED'); + }, + ); + + it('records the structure bypass with the collector-supported non-run status', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const d = repo(['size']); + const sink = join(d, 'events.jsonl'); + process.env.GUARD_STRUCTURE_OK = '1'; + process.env.DEVKIT_GATE_EVENTS = sink; + + expect(runDeterministic(d, { exec: mkExec({}), structure: 'bunx eslint src' })).toBe(0); + const events = readFileSync(sink, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((event) => event.type === 'gate_result'); + expect(events).toContainEqual( + expect.objectContaining({ + gate: 'structure-lint', + status: 'could_not_run', + detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)', + }), + ); + }); + + it('does not re-emit the structure bypass when a prefix-cache hit skips the retry', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const d = repo(['size']); + execFileSync('git', ['init', '-q'], { cwd: d }); + execFileSync('git', ['add', '.'], { cwd: d }); + const sink = join(d, 'events.jsonl'); + const exec = mkExec({}); + process.env.DEVKIT_SHIP = '1'; + process.env.GUARD_STRUCTURE_OK = '1'; + process.env.DEVKIT_GATE_EVENTS = sink; + + expect(runDeterministic(d, { exec, structure: 'bunx eslint src' })).toBe(0); + expect(runDeterministic(d, { exec, structure: 'bunx eslint src' })).toBe(0); + + expect(exec).toHaveBeenCalledTimes(1); // first run executes size; cached retry executes nothing + expect( + log.mock.calls.flat().filter((line) => String(line).includes('Structure lint BYPASSED')), + ).toHaveLength(1); + const bypassEvents = readFileSync(sink, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter( + (event) => + event.gate === 'structure-lint' && + event.status === 'could_not_run' && + event.detail === 'structure-lint(bypassed:GUARD_STRUCTURE_OK)', + ); + expect(bypassEvents).toHaveLength(1); + }); + + it('a structure failure prints the explicit base-debt remedy', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const d = repo(['size']); + expect( + runDeterministic(d, { + exec: mkExec({ 'structure/run.mts': 1 }), + structure: 'guard-structure gate', + }), + ).toBe(1); + expect(err.mock.calls.flat().join('\n')).toContain('export GUARD_STRUCTURE_OK=1'); + }); + it('--extra gates run under their own label and aggregate with the built-ins', () => { const err = vi.spyOn(console, 'error').mockImplementation(() => {}); const d = repo(['size']); @@ -367,6 +450,28 @@ describe('prefixCacheScope', () => { expect(prefixCacheScope()).toBeUndefined(); expect(prefixCacheScope('custom')).toBe('custom'); }); + + it.each(['GUARD_STRUCTURE_OK', 'GUARD_NO_STRUCTURE'])( + '%s salts the scope away from a normal structure run', + (key) => { + const cleanDefault = prefixCacheScope(); + const cleanCustom = prefixCacheScope('custom'); + process.env[key] = '1'; + expect(prefixCacheScope()).toBe('devkit-guards:structure-bypassed'); + expect(prefixCacheScope('custom')).toBe('custom:structure-bypassed'); + expect(prefixCacheScope()).not.toBe(cleanDefault); + expect(prefixCacheScope('custom')).not.toBe(cleanCustom); + }, + ); + + it('the structure salt composes after strict and coverage salts', () => { + process.env.GUARD_DETERMINISTIC_STRICT = '1'; + process.env.GUARD_COVERAGE_OK = '1'; + process.env.GUARD_STRUCTURE_OK = '1'; + expect(prefixCacheScope()).toBe( + 'devkit-guards:deterministic-strict:coverage-bypassed:structure-bypassed', + ); + }); }); // A gate that opts out proved nothing, but its own stderr scrolls past at the same weight as a gate diff --git a/gate-engine/deterministic/run.mts b/gate-engine/deterministic/run.mts index 2f08f426..9076510e 100644 --- a/gate-engine/deterministic/run.mts +++ b/gate-engine/deterministic/run.mts @@ -42,7 +42,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { coverageBypassed, deterministicStrict } from '../config.mts'; +import { coverageBypassed, deterministicStrict, structureBypassed } from '../config.mts'; import { emitGateEvent, finishGateTiming } from '../judge/gate-events.mts'; import { prefixEntry, recordPrefix } from '../prefix-cache/prefix-cache.mts'; @@ -216,7 +216,15 @@ export function prefixCacheScope(scope?: string, effectiveIds?: string[]): strin // bypassed run cannot reuse a clean run's key either, so it re-runs the other gates. Same trade // the review salt makes — correctness over a cache hit.) `?? 'devkit-guards'` materialises the // default scope checkPrefix would otherwise supply internally, or the salt would be lost. - return coverageBypassed() ? `${strictBase ?? 'devkit-guards'}:coverage-bypassed` : strictBase; + const coverageBase = coverageBypassed() + ? `${strictBase ?? 'devkit-guards'}:coverage-bypassed` + : strictBase; + // Structure commands are arbitrary (`guard-structure`, Electron's eslint invocation, devkit's + // own package script), so the bypass lives at this orchestrator layer. Keep its cache namespace + // apart from a normal run for the same anti-laundering reason as coverage above. + return structureBypassed() + ? `${coverageBase ?? 'devkit-guards'}:structure-bypassed` + : coverageBase; } // Run one gate as a subprocess; return its exit code (0 on success). stdio inherited so the gate's @@ -294,11 +302,25 @@ export function runDeterministic(cwd = process.cwd(), opts: RunDeterministicOpts // every gate. checkPrefix returns true = skip, false = run. const cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope }); const skip = Boolean(cachedPrefix); + const bypassStructure = Boolean(opts.structure) && structureBypassed(); const fails = []; // Gates that opted out (exit 2 where that IS an opt-out) and so proved nothing. Reported even on a // green run — the whole defect this exists for is a skipped gate reading like a passed one. const skipped: string[] = []; if (!skip) { + if (bypassStructure) { + console.log('⚠️ Structure lint BYPASSED for this run (GUARD_STRUCTURE_OK=1).'); + console.log(' Repository structure was NOT verified for this commit.'); + emitGateEvent({ + type: 'gate_result', + gate: 'structure-lint', + // The collector's gate_result schema accepts fail | could_not_run. Keep the deliberate + // bypass measurable as a non-run, and distinguish it from an infrastructure opt-out in + // detail instead of inventing a status that downstream readers would treat as clean. + status: 'could_not_run', + detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)', + }); + } const ids = new Set(effectiveIds); const gates: Gate[] = DETERMINISTIC.filter((g) => ids.has(g.id)).map((g) => ({ label: `guard-${g.id}`, @@ -306,7 +328,9 @@ export function runDeterministic(cwd = process.cwd(), opts: RunDeterministicOpts failOpen2: true, })); for (const x of opts.extra ?? []) gates.push(commandGate(x.label, x.cmd)); - if (opts.structure) gates.push(commandGate('structure-lint', opts.structure)); + if (opts.structure && !bypassStructure) { + gates.push(commandGate('structure-lint', opts.structure)); + } for (const gate of gates) { if (!gate.argv) { fails.push(`${gate.label}(unrunnable: empty command)`); @@ -365,6 +389,15 @@ export function runDeterministic(cwd = process.cwd(), opts: RunDeterministicOpts console.error( ' Every deterministic failure is listed above — fix them together, then commit once.', ); + if (fails.some((f) => f.startsWith('structure-lint'))) { + console.error( + ' Base branch structure debt that your diff did not cause? Re-run with the explicit', + ); + console.error(' one-run assertion: export GUARD_STRUCTURE_OK=1'); + console.error( + ' A structure violation introduced by your own change must be fixed instead.', + ); + } if (fails.some((f) => NOT_FOUND_RE.test(f))) { console.error( ' exit 127 = command not found: the gate ran, but its BINARY did not resolve — a',