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
1 change: 1 addition & 0 deletions docs/decisions/qavis-advisory-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ created: 2026-07-07
**Source:** collab · qavis-advisory
- 2026-07-22 — Fail-open stays exit 0 but is now LOUD: when the advisory cannot run, the gate prints one stderr line naming WHY (qavis not on PATH / route failed / unparseable verdict) instead of returning a bare null that read exactly like SILENT. defaultRoute now returns a RouteResult whose null arm carries the reason, so there is one printer and no detail is discarded; ENOENT is discriminated from a qavis that ran and failed. Silent fail-open made a dead gate indistinguishable from 'nothing to QA'.
- 2026-07-22 — devkit doctor now reports the advisory gate's liveness outside commit time: recipe present + qavis on PATH, printed in all three doctor modes. Advisory only — never a CheckResult and never a --fix target, since a repo that keeps the guard but skips installing qavis made a choice, not drift. Resolved against the git root, the cwd the husky fragment shells the gate from. Paid for the doctor.mts size ratchet (grandfathered at 894, shrink-only) by extracting the four synced-asset drift checks and the CheckResult primitive into cli/lib/doctor/ — which also let commit-msg-block.mts import the type instead of restating it structurally to dodge an import cycle.
- 2026-07-28 — sc-1307: An ADVISE remedy now forces qavis qa --route vision because the gate's diff-aware classifier intentionally catches semantic UI impact in backend-only paths that qa's auto filename router can skip. A skip receipt was rejected: it would waive exactly those backend-to-UI cases. The classifier's deterministic launchability pre-gate guarantees the forced vision route is available.
52 changes: 50 additions & 2 deletions gate-engine/qavis-advisory/__tests__/qavis-advisory.test.mts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AdvisoryDeps, qavisOnPath, type RouteResult, runQavisAdvisory } from '../check.mts';

const ENV_KEYS = ['GUARD_AI_STRICT', 'GUARD_QAVIS_OK', 'GUARD_NO_QAVIS_ADVISORY'];
const ENV_KEYS = [
'GUARD_AI_STRICT',
'GUARD_QAVIS_OK',
'GUARD_NO_QAVIS_ADVISORY',
'PATH',
'QAVIS_CONTRACT_RECEIPT',
'QAVIS_CONTRACT_REPO',
];
const saved: Record<string, string | undefined> = {};

beforeEach(() => {
Expand Down Expand Up @@ -54,6 +62,46 @@ describe('runQavisAdvisory exit contract', () => {
expect(runQavisAdvisory('/r', advise)).toBe(3);
});

it('the emitted remediation runs forced vision and its receipt clears the next strict gate', () => {
const repo = mkdtempSync(path.join(tmpdir(), 'qavis-advisory-contract-'));
const binDir = mkdtempSync(path.join(tmpdir(), 'qavis-advisory-bin-'));
const receipt = path.join(repo, 'pass-receipt');
const qavis = path.join(binDir, 'qavis');
writeFileSync(
qavis,
`#!/usr/bin/env node
const { existsSync, writeFileSync } = require('node:fs');
const args = process.argv.slice(2);
const receipt = process.env.QAVIS_CONTRACT_RECEIPT;
const route = ['route', '--staged', '--gate', '--repo', process.env.QAVIS_CONTRACT_REPO];
const qa = ['qa', '--staged', '--route', 'vision', '--repo', '.'];
if (JSON.stringify(args) === JSON.stringify(route)) {
process.stdout.write(existsSync(receipt) ? 'SILENT\\n' : 'ADVISE\\n');
process.exit(0);
}
if (JSON.stringify(args) === JSON.stringify(qa)) {
writeFileSync(receipt, 'pass');
process.exit(0);
}
process.exit(2);
`,
);
chmodSync(qavis, 0o755);
process.env.GUARD_AI_STRICT = '1';
process.env.PATH = [binDir, saved.PATH].filter(Boolean).join(path.delimiter);
process.env.QAVIS_CONTRACT_RECEIPT = receipt;
process.env.QAVIS_CONTRACT_REPO = repo;

expect(runQavisAdvisory(repo, { hasRecipe: () => true })).toBe(3);
const command = stderr().match(/Run:\s+(qavis qa.*?)\s+\(a pass writes/)?.[1];
expect(command).toBeDefined();
const [executable, ...args] = command?.split(/\s+/) ?? [];
execFileSync(executable as string, args, { cwd: repo });

expect(existsSync(receipt)).toBe(true);
expect(runQavisAdvisory(repo, { hasRecipe: () => true })).toBe(0);
});

it('GUARD_QAVIS_OK short-circuits ADVISE under strict → 0, never shells qavis', () => {
process.env.GUARD_AI_STRICT = '1';
process.env.GUARD_QAVIS_OK = '1';
Expand Down
2 changes: 1 addition & 1 deletion gate-engine/qavis-advisory/check.mts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function runQavisAdvisory(cwd: string = process.cwd(), deps: AdvisoryDeps
// qavis printed its own reason to stderr; add the remedy + the exit-code decision.
console.error('qavis-advisory: UI-affecting change with no qavis QA on this staged tree.');
console.error(
' Run: qavis qa --staged --repo . (a pass writes a receipt that clears this)',
' Run: qavis qa --staged --route vision --repo . (a pass writes a receipt that clears this)',
);
console.error(' Skip: export GUARD_QAVIS_OK=1, or disable with GUARD_NO_QAVIS_ADVISORY=1.');
return envFlag('AI_STRICT') ? 3 : 0; // ship blocks; a normal commit is advisory-only
Expand Down
Loading