qualia-framework 7.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // ~/.claude/hooks/secret-guard.js — block commits that stage secrets.
3
+ //
4
+ // Constitution non-negotiables: the service_role key is server-only and must
5
+ // never be committed; .env files must never be committed. Those were prose-only
6
+ // until now. This is the deterministic gate: a PreToolUse hook on
7
+ // `git commit*` that scans STAGED content (git diff --cached) for high-signal
8
+ // secret patterns and a staged `.env` file, and BLOCKS the commit (exit 2) if
9
+ // any are found.
10
+ //
11
+ // Design mirrors pre-deploy-gate.js: self-filter on the command, fail-CLOSED on
12
+ // a real match, OWNER-only escape (QUALIA_SECRET_SKIP=1), trace every decision.
13
+ // It NEVER prints the matched secret value — only the pattern name and file.
14
+ //
15
+ // Exits 2 to BLOCK. Exits 0 to allow. Cross-platform. No external deps.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const os = require("os");
20
+ const { spawnSync } = require("child_process");
21
+
22
+ const _traceStart = Date.now();
23
+
24
+ function qualiaHome() {
25
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
26
+ const parent = path.basename(path.dirname(__dirname));
27
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
28
+ return path.join(os.homedir(), ".claude");
29
+ }
30
+
31
+ const QUALIA_HOME = qualiaHome();
32
+ const CONFIG = path.join(QUALIA_HOME, ".qualia-config.json");
33
+ const SHELL = process.platform === "win32";
34
+ let HOOK_COMMAND = null;
35
+
36
+ // Self-filter: only act on `git commit`. Direct invocation (no stdin) runs the
37
+ // full scan so the test suite and manual runs work.
38
+ (function selfFilter() {
39
+ if (process.stdin.isTTY) return;
40
+ let command = null;
41
+ try {
42
+ const raw = fs.readFileSync(0, "utf8");
43
+ if (raw) {
44
+ const payload = JSON.parse(raw);
45
+ command = (payload && payload.tool_input && payload.tool_input.command) || "";
46
+ }
47
+ } catch {}
48
+ if (command === null) return; // no/!malformed stdin — run full scan
49
+ HOOK_COMMAND = command;
50
+ if (!/\bgit\s+commit\b/.test(command)) process.exit(0);
51
+ })();
52
+
53
+ function readConfig() {
54
+ try {
55
+ return JSON.parse(fs.readFileSync(CONFIG, "utf8"));
56
+ } catch {
57
+ return {};
58
+ }
59
+ }
60
+
61
+ function _trace(result, extra) {
62
+ try {
63
+ const traceDir = path.join(QUALIA_HOME, ".qualia-traces");
64
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
65
+ const entry = { hook: "secret-guard", result, timestamp: new Date().toISOString(), duration_ms: Date.now() - _traceStart, ...extra };
66
+ fs.appendFileSync(path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`), JSON.stringify(entry) + "\n");
67
+ } catch {}
68
+ }
69
+
70
+ // High-signal secret patterns. Narrow on purpose — false positives on a commit
71
+ // gate are expensive, so each pattern targets a real credential shape.
72
+ const SECRET_PATTERNS = [
73
+ { name: "private key block", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/ },
74
+ { name: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
75
+ { name: "OpenAI/Anthropic-style secret key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
76
+ { name: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
77
+ { name: "Supabase service_role key assignment", re: /SUPABASE_SERVICE_ROLE_KEY\s*[:=]\s*['"]?[A-Za-z0-9._-]{20,}/ },
78
+ { name: "service_role JWT", re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}[\s\S]{0,400}service_role/ },
79
+ ];
80
+
81
+ // A staged .env file (real env, not an example/template) is a leak by itself.
82
+ function stagedEnvFiles(names) {
83
+ return names.filter((f) => {
84
+ const base = path.basename(f);
85
+ if (!/^\.env(\.|$)/.test(base)) return false;
86
+ return !/\.(example|sample|template|dist)$/.test(base) && base !== ".env.example";
87
+ });
88
+ }
89
+
90
+ function git(args) {
91
+ return spawnSync("git", args, { encoding: "utf8", timeout: 8000, shell: SHELL });
92
+ }
93
+
94
+ function scan() {
95
+ // Staged file names.
96
+ const nameRes = git(["diff", "--cached", "--name-only"]);
97
+ if (nameRes.status !== 0) return { skipped: "not-a-repo-or-git-error" };
98
+ const names = (nameRes.stdout || "").split(/\r?\n/).filter(Boolean);
99
+ if (names.length === 0) return { clean: true, reason: "nothing-staged" };
100
+
101
+ const findings = [];
102
+
103
+ for (const env of stagedEnvFiles(names)) {
104
+ findings.push({ file: env, pattern: "staged .env file (never commit secrets)" });
105
+ }
106
+
107
+ // Added lines only (the `+` side of the staged diff).
108
+ const diffRes = git(["diff", "--cached", "--unified=0"]);
109
+ const diff = diffRes.stdout || "";
110
+ let currentFile = "?";
111
+ for (const line of diff.split(/\r?\n/)) {
112
+ if (line.startsWith("+++ b/")) { currentFile = line.slice(6); continue; }
113
+ if (!line.startsWith("+") || line.startsWith("+++")) continue;
114
+ for (const p of SECRET_PATTERNS) {
115
+ if (p.re.test(line)) findings.push({ file: currentFile, pattern: p.name });
116
+ }
117
+ }
118
+ return { findings };
119
+ }
120
+
121
+ function blockAllowedByOwner() {
122
+ if (process.env.QUALIA_SECRET_SKIP !== "1" && !/\bQUALIA_SECRET_SKIP=1\b/.test(HOOK_COMMAND || "")) return false;
123
+ const role = String(readConfig().role || "").toUpperCase();
124
+ if (role === "OWNER") return true;
125
+ console.error("BLOCKED: QUALIA_SECRET_SKIP is OWNER-only.");
126
+ _trace("block", { reason: "skip-non-owner" });
127
+ process.exit(2);
128
+ }
129
+
130
+ let result;
131
+ try {
132
+ result = scan();
133
+ } catch (e) {
134
+ // Fail OPEN on a scan error — a commit gate that errors must not brick every
135
+ // commit. A real match (below) still blocks.
136
+ console.error(`⚠ secret-guard: scan error (${e.message}). Commit proceeding.`);
137
+ _trace("warn", { error: e.message });
138
+ process.exit(0);
139
+ }
140
+
141
+ if (result && result.findings && result.findings.length > 0) {
142
+ if (blockAllowedByOwner()) {
143
+ console.error("⚠ secret-guard: findings present but QUALIA_SECRET_SKIP=1 (OWNER). Commit proceeding.");
144
+ _trace("allow", { skipped_owner: true, count: result.findings.length });
145
+ process.exit(0);
146
+ }
147
+ console.error("BLOCKED: secret(s) detected in staged content. Unstage and remove before committing.");
148
+ // Dedupe file+pattern; never print the matched value.
149
+ const seen = new Set();
150
+ for (const f of result.findings) {
151
+ const key = `${f.file}::${f.pattern}`;
152
+ if (seen.has(key)) continue;
153
+ seen.add(key);
154
+ console.error(` ✗ ${f.file} — ${f.pattern}`);
155
+ }
156
+ console.error(" Fix: move the secret to an env var / secrets manager; add the file to .gitignore.");
157
+ _trace("block", { count: seen.size });
158
+ process.exit(2);
159
+ }
160
+
161
+ _trace("allow", result || {});
162
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "description": "Claude Code and Codex workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework": "./bin/cli.js"
@@ -30,6 +30,9 @@
30
30
  "test:lib": "bash tests/lib.test.sh",
31
31
  "test:skills": "bash tests/skills.test.sh",
32
32
  "test:slop-detect": "bash tests/slop-detect.test.sh",
33
+ "test:dep-verify": "bash tests/dep-verify.test.sh",
34
+ "test:runtime-parity": "bash tests/runtime-parity.test.sh",
35
+ "test:secret-guard": "bash tests/secret-guard.test.sh",
33
36
  "test:statusline": "bash tests/statusline.test.sh",
34
37
  "test:refs": "bash tests/refs.test.sh",
35
38
  "test:published-install": "bash tests/published-install-smoke.test.sh",
@@ -172,11 +172,18 @@ Status protocol (machine-readable fan-in — do this, do not skip):
172
172
  (use BLOCKED or PARTIAL with `--note \"why\"` instead of DONE if you could not finish)
173
173
 
174
174
  Execute. Commit. Write your DONE/BLOCKED/PARTIAL status. Return DONE/BLOCKED/PARTIAL.
175
- ", subagent_type="qualia-builder", description="Task {N}: {title}")
175
+ ", subagent_type="qualia-builder", model="{tier}", description="Task {N}: {title}")
176
176
  ```
177
177
 
178
178
  **Cache ordering:** Role + grounding FIRST, phase_context SECOND, task_context LAST. Stable prefix ~2-5k tokens → 92% cache hit. Pre-inline eliminates 3-5 Read calls per builder.
179
179
 
180
+ **Model routing (intelligent model routing — the OpEx lever).** Most builders are *implementers*, not architects: they follow a precise task contract (files, AC, validation) within established patterns. That is exactly the deterministic, low-ambiguity work that belongs on a cheaper, faster model — reserving the frontier model for the judgment-heavy steps (planning, verification, skeptic adjudication). Per task, pass `model=`:
181
+ - **`sonnet`** (default for builders) — single-/few-file tasks against established patterns, CRUD, wiring, styling, test scaffolding. The bulk of every phase.
182
+ - **frontier (omit `model=` → inherits the session model)** — tasks the contract marks high-complexity, the `architect` persona, novel algorithms, or anything touching auth/payments/migrations where a wrong-but-plausible implementation is expensive.
183
+ - **`haiku`** — pure mechanical edits (rename sweeps, import fixes, format-only changes); pairs naturally with `/qualia-build --batch`.
184
+
185
+ When unsure, default to `sonnet`; escalate only when the task carries real ambiguity or correctness risk. This routes the high-volume mechanical work off the frontier model without weakening the steps where model strength actually changes the outcome.
186
+
180
187
  **After each task:**
181
188
  - Verify commit: `git log --oneline -1`
182
189
  - Show:
@@ -66,9 +66,10 @@ if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.
66
66
  if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)"; then npm test; fi
67
67
  npm run build # Build — must succeed
68
68
  node ${QUALIA_BIN}/slop-detect.mjs --severity=critical # Anti-slop — CRITICAL design tells block ship
69
+ node ${QUALIA_BIN}/dep-verify.mjs --severity=critical # Hallucinated/slopsquatted imports block ship
69
70
  ```
70
71
 
71
- The `pre-deploy-gate.js` hook re-runs the anti-slop scan at `vercel --prod` time as the hard, non-bypassable gate (OWNER-only `QUALIA_SKIP_SLOP=1` escape). This step surfaces failures early so they're fixed before the deploy command.
72
+ `dep-verify` blocks ship when any import references a package that is neither declared in `package.json` nor installed in `node_modules` — catching AI-invented or typosquatted dependencies before they reach production. The `pre-deploy-gate.js` hook re-runs **both** scans at `vercel --prod` time as the hard, non-bypassable gate — anti-slop (OWNER-only `QUALIA_SKIP_SLOP=1` escape) and dep-verify (OWNER-only `QUALIA_SKIP_DEPCHECK=1` escape, for the rare known-good import not yet in `package.json`). Both gates fail **closed**: a crash or timeout in either scanner blocks the deploy rather than passing silently. This step surfaces failures early so they're fixed before the deploy command.
72
73
 
73
74
  On failure:
74
75
  1. Summarize what failed in plain language
@@ -76,9 +76,11 @@ Verify the {L} concerns of this phase. Every finding needs file:line evidence an
76
76
  Write your findings as a JSON array to .planning/phase-{N}-panel-{L}.json:
77
77
  [{\"file\":\"path\",\"line\":N,\"severity\":\"CRITICAL|HIGH|MEDIUM|LOW\",\"title\":\"one-line claim\"}]
78
78
  Empty array [] if the {L} lens is clean. Also append a human '## {L} lens' section to .planning/phase-{N}-verification.md.
79
- ", subagent_type="qualia-verifier", description="Verify phase {N} — {L} lens")
79
+ ", subagent_type="qualia-verifier", model="sonnet", description="Verify phase {N} — {L} lens")
80
80
  ```
81
81
 
82
+ **Model routing.** Panel verifiers do *finding* work — grep code against acceptance criteria and the shared machine evidence — which is high-recall but low-judgment, so they run on **`sonnet`**. Spend the frontier model where the verdict is actually decided: the skeptic vote (§3c step 2). This is the intelligent-model-routing OpEx lever applied to verification — cheaper on the wide finding pass, frontier on the narrow adjudication.
83
+
82
84
  ### 3b. Browser QA (if phase touched frontend)
83
85
 
84
86
  If plan Files include `.tsx`/`.jsx`/`.css`/`.scss` or `app/`/`pages/`/`components/` paths, spawn browser QA parallel:
@@ -129,6 +131,8 @@ Return exactly one line: REAL — {file:line reason} OR NOT_REAL — {file:l
129
131
  ", subagent_type="qualia-verifier", description="Skeptic {i}/3 — {title}")
130
132
  ```
131
133
 
134
+ Skeptics deliberately **omit `model=`** so they inherit the session's frontier model: their REAL/NOT_REAL judgment is what flips a CRITICAL/HIGH verdict, and that is the one step in the pipeline where model strength most changes the outcome. Route cheap on the finding pass, never on the adjudication.
135
+
132
136
  Tally each finding's votes into `.planning/phase-{N}-panel.json` (`votes.real` / `votes.notReal`).
133
137
 
134
138
  **3. Aggregate** deterministically:
@@ -196,13 +200,16 @@ Write the deterministic eval artifact before changing state:
196
200
  node ${QUALIA_BIN}/harness-eval.js --phase {N} --run --write
197
201
  ```
198
202
 
199
- Run the zero-token anti-slop scan as a deterministic gate (same role as `migration-guard`/`branch-guard` — the scanner exits non-zero on CRITICAL design tells). A CRITICAL finding is a verification FAIL, not a soft note:
203
+ Run the zero-token deterministic gates (same role as `migration-guard`/`branch-guard` — each exits non-zero on a hard fault). A non-zero exit is a verification FAIL, not a soft note:
200
204
 
201
205
  ```bash
202
- node ${QUALIA_BIN}/slop-detect.mjs --severity=critical
206
+ node ${QUALIA_BIN}/slop-detect.mjs --severity=critical # CRITICAL design tells (the slop half)
207
+ node ${QUALIA_BIN}/dep-verify.mjs --severity=critical # hallucinated/slopsquatted imports (the correctness half)
203
208
  ```
204
209
 
205
- The phase is PASS only if ALL of these agree: the panel verdict (§3c `verify-panel.js` exit 0), the harness-eval status, and the anti-slop scan. If any is FAIL/non-zero, mark the phase FAIL. The state machine also refuses PASS when a contract exists but `.planning/evidence/phase-{N}-contract-run.json` is missing/failing, or when the verification report contains `INSUFFICIENT EVIDENCE`.
210
+ `dep-verify` flags any import whose package is BOTH undeclared in `package.json` AND absent from `node_modules` the exact signature of an AI-invented or typosquatted dependency (the #1 named AI-generated-code security failure mode). It is the correctness/security companion to the design-focused `slop-detect`.
211
+
212
+ The phase is PASS only if ALL of these agree: the panel verdict (§3c `verify-panel.js` exit 0), the harness-eval status, the anti-slop scan, and the dependency scan. If any is FAIL/non-zero, mark the phase FAIL. The state machine also refuses PASS when a contract exists but `.planning/evidence/phase-{N}-contract-run.json` is missing/failing, or when the verification report contains `INSUFFICIENT EVIDENCE`.
206
213
 
207
214
  ```bash
208
215
  node ${QUALIA_BIN}/state.js transition --to verified --phase {N} --verification {pass|fail} --evidence .planning/evals/harness-eval-*.json
package/tests/bin.test.sh CHANGED
@@ -495,8 +495,8 @@ fi
495
495
  # usage-capture added in v6.9.1 — UserPromptSubmit telemetry capture;
496
496
  # task-write-guard added in v6.13 — R1 runtime plan-contract file-scope guard)
497
497
  HOOK_COUNT=$(ls "$TMP/.claude/hooks/"*.js 2>/dev/null | wc -l)
498
- if [ "$HOOK_COUNT" -eq 15 ]; then
499
- pass "15 hooks installed in hooks/ (incl. task-write-guard v6.13)"
498
+ if [ "$HOOK_COUNT" -eq 16 ]; then
499
+ pass "16 hooks installed in hooks/ (incl. task-write-guard + secret-guard)"
500
500
  else
501
501
  fail_case "hook count" "got $HOOK_COUNT"
502
502
  fi
@@ -0,0 +1,247 @@
1
+ #!/bin/bash
2
+ # Qualia Framework — bin/dep-verify.mjs behavior tests
3
+ # Verifies the hallucinated/slopsquatted-dependency gate catches what it claims:
4
+ # imports that are neither declared in package.json nor installed in node_modules.
5
+ #
6
+ # Run: bash tests/dep-verify.test.sh
7
+
8
+ PASS=0
9
+ FAIL=0
10
+ DEP_VERIFY="$(cd "$(dirname "$0")/../bin" && pwd)/dep-verify.mjs"
11
+ NODE="${NODE:-node}"
12
+
13
+ TMP_DIRS=()
14
+ cleanup() {
15
+ for d in "${TMP_DIRS[@]}"; do
16
+ [ -d "$d" ] && rm -rf "$d"
17
+ done
18
+ }
19
+ trap cleanup EXIT
20
+
21
+ mktmp() {
22
+ local TMP
23
+ TMP=$(mktemp -d)
24
+ TMP_DIRS+=("$TMP")
25
+ echo "$TMP"
26
+ }
27
+
28
+ pass() { echo " ✓ $1"; PASS=$((PASS + 1)); }
29
+ fail_case() { echo " ✗ $1"; echo " $2"; FAIL=$((FAIL + 1)); }
30
+
31
+ echo "dep-verify.test.sh — bin/dep-verify.mjs behavioral tests"
32
+ echo ""
33
+
34
+ # ── Sanity: file exists and parses ────────────────────────────────────
35
+ if [ ! -f "$DEP_VERIFY" ]; then
36
+ fail_case "dep-verify exists" "$DEP_VERIFY not found"
37
+ echo "=== Results: $PASS passed, $FAIL failed ==="
38
+ exit 1
39
+ fi
40
+ pass "dep-verify.mjs exists at expected path"
41
+
42
+ if $NODE --check "$DEP_VERIFY" 2>/dev/null; then
43
+ pass "dep-verify.mjs syntax is valid"
44
+ else
45
+ fail_case "syntax check" "node --check failed on $DEP_VERIFY"
46
+ fi
47
+
48
+ # ── Declared dependency: should pass (exit 0) ─────────────────────────
49
+ TMP=$(mktmp)
50
+ cat > "$TMP/package.json" <<'EOF'
51
+ { "name": "fixture", "dependencies": { "react": "^19.0.0" } }
52
+ EOF
53
+ cat > "$TMP/declared.tsx" <<'EOF'
54
+ import { useState } from 'react';
55
+ import { thing } from './local-module';
56
+ import fs from 'node:fs';
57
+ export const x = useState;
58
+ EOF
59
+ EXIT_CODE=0
60
+ $NODE "$DEP_VERIFY" "$TMP/declared.tsx" >/dev/null 2>&1 || EXIT_CODE=$?
61
+ if [ "$EXIT_CODE" = "0" ]; then
62
+ pass "exits 0 when imports are declared / relative / builtin"
63
+ else
64
+ fail_case "declared deps" "expected exit 0, got $EXIT_CODE"
65
+ fi
66
+
67
+ # ── Hallucinated package: undeclared AND not installed → exit 1 ────────
68
+ TMP2=$(mktmp)
69
+ cat > "$TMP2/package.json" <<'EOF'
70
+ { "name": "fixture", "dependencies": { "react": "^19.0.0" } }
71
+ EOF
72
+ cat > "$TMP2/bad.tsx" <<'EOF'
73
+ import { toast } from 'react-use-magic-toast-9000';
74
+ export const t = toast;
75
+ EOF
76
+ EXIT_CODE=0
77
+ OUT=$($NODE "$DEP_VERIFY" "$TMP2/bad.tsx" 2>&1) || EXIT_CODE=$?
78
+ if [ "$EXIT_CODE" = "1" ] && echo "$OUT" | grep -q "react-use-magic-toast-9000"; then
79
+ pass "exits 1 and names the hallucinated package"
80
+ else
81
+ fail_case "hallucinated dep" "exit=$EXIT_CODE out=$(echo "$OUT" | head -c 120)"
82
+ fi
83
+
84
+ # ── Installed-but-undeclared (phantom): should NOT flag ───────────────
85
+ TMP3=$(mktmp)
86
+ cat > "$TMP3/package.json" <<'EOF'
87
+ { "name": "fixture", "dependencies": {} }
88
+ EOF
89
+ mkdir -p "$TMP3/node_modules/left-pad"
90
+ cat > "$TMP3/uses-installed.js" <<'EOF'
91
+ const lp = require('left-pad');
92
+ module.exports = lp;
93
+ EOF
94
+ EXIT_CODE=0
95
+ $NODE "$DEP_VERIFY" "$TMP3/uses-installed.js" >/dev/null 2>&1 || EXIT_CODE=$?
96
+ if [ "$EXIT_CODE" = "0" ]; then
97
+ pass "does NOT flag a package that is installed in node_modules (low false positives)"
98
+ else
99
+ fail_case "installed phantom" "expected exit 0, got $EXIT_CODE"
100
+ fi
101
+
102
+ # ── tsconfig path alias should be ignored ─────────────────────────────
103
+ TMP4=$(mktmp)
104
+ cat > "$TMP4/package.json" <<'EOF'
105
+ { "name": "fixture", "dependencies": {} }
106
+ EOF
107
+ cat > "$TMP4/tsconfig.json" <<'EOF'
108
+ { "compilerOptions": { "paths": { "@app/*": ["src/*"] } } }
109
+ EOF
110
+ cat > "$TMP4/alias.tsx" <<'EOF'
111
+ import { Button } from '@app/components/button';
112
+ export const B = Button;
113
+ EOF
114
+ EXIT_CODE=0
115
+ $NODE "$DEP_VERIFY" "$TMP4/alias.tsx" >/dev/null 2>&1 || EXIT_CODE=$?
116
+ if [ "$EXIT_CODE" = "0" ]; then
117
+ pass "ignores tsconfig path aliases (@app/*)"
118
+ else
119
+ fail_case "path alias" "expected exit 0, got $EXIT_CODE"
120
+ fi
121
+
122
+ # ── Commented-out import must not trigger ─────────────────────────────
123
+ TMP5=$(mktmp)
124
+ cat > "$TMP5/package.json" <<'EOF'
125
+ { "name": "fixture", "dependencies": {} }
126
+ EOF
127
+ cat > "$TMP5/commented.ts" <<'EOF'
128
+ // import { x } from 'definitely-not-real-pkg';
129
+ export const y = 1;
130
+ EOF
131
+ EXIT_CODE=0
132
+ $NODE "$DEP_VERIFY" "$TMP5/commented.ts" >/dev/null 2>&1 || EXIT_CODE=$?
133
+ if [ "$EXIT_CODE" = "0" ]; then
134
+ pass "ignores commented-out imports"
135
+ else
136
+ fail_case "commented import" "expected exit 0, got $EXIT_CODE"
137
+ fi
138
+
139
+ # ── String/regex awareness: import sharing a line with a // -bearing
140
+ # string or regex literal must STILL be caught (no silent false negative) ──
141
+ TMP6=$(mktmp)
142
+ cat > "$TMP6/package.json" <<'EOF'
143
+ { "name": "fixture", "dependencies": {} }
144
+ EOF
145
+ cat > "$TMP6/sameline.ts" <<'EOF'
146
+ const re = /url:\/\//; import { x } from 'hallucinated-sameline';
147
+ EOF
148
+ EXIT_CODE=0
149
+ OUT=$($NODE "$DEP_VERIFY" "$TMP6/sameline.ts" 2>&1) || EXIT_CODE=$?
150
+ if [ "$EXIT_CODE" = "1" ] && echo "$OUT" | grep -q "hallucinated-sameline"; then
151
+ pass "catches an import on the same line as a regex literal (no false negative)"
152
+ else
153
+ fail_case "regex-sameline FN" "exit=$EXIT_CODE out=$(echo "$OUT" | head -c 120)"
154
+ fi
155
+
156
+ # ── Import syntax INSIDE a string literal must NOT be flagged (no FP) ──
157
+ TMP7=$(mktmp)
158
+ cat > "$TMP7/package.json" <<'EOF'
159
+ { "name": "fixture", "dependencies": {} }
160
+ EOF
161
+ cat > "$TMP7/strlit.ts" <<'EOF'
162
+ export const example = "import { fake } from 'made-up-package'";
163
+ EOF
164
+ EXIT_CODE=0
165
+ $NODE "$DEP_VERIFY" "$TMP7/strlit.ts" >/dev/null 2>&1 || EXIT_CODE=$?
166
+ if [ "$EXIT_CODE" = "0" ]; then
167
+ pass "does NOT flag import syntax inside a string literal (no false positive)"
168
+ else
169
+ fail_case "string-literal FP" "expected exit 0, got $EXIT_CODE"
170
+ fi
171
+
172
+ # ── Scoped hallucinated package → flagged and named ───────────────────
173
+ TMP8=$(mktmp)
174
+ cat > "$TMP8/package.json" <<'EOF'
175
+ { "name": "fixture", "dependencies": {} }
176
+ EOF
177
+ cat > "$TMP8/scoped.tsx" <<'EOF'
178
+ import { thing } from '@hallucinated-scope/nonexistent-pkg';
179
+ export const t = thing;
180
+ EOF
181
+ EXIT_CODE=0
182
+ OUT=$($NODE "$DEP_VERIFY" "$TMP8/scoped.tsx" 2>&1) || EXIT_CODE=$?
183
+ if [ "$EXIT_CODE" = "1" ] && echo "$OUT" | grep -q "@hallucinated-scope/nonexistent-pkg"; then
184
+ pass "flags a scoped hallucinated package (@scope/name)"
185
+ else
186
+ fail_case "scoped dep" "exit=$EXIT_CODE out=$(echo "$OUT" | head -c 120)"
187
+ fi
188
+
189
+ # ── Protocol specifier (virtual:/https:) must NOT be flagged ──────────
190
+ TMP9=$(mktmp)
191
+ cat > "$TMP9/package.json" <<'EOF'
192
+ { "name": "fixture", "dependencies": {} }
193
+ EOF
194
+ cat > "$TMP9/proto.ts" <<'EOF'
195
+ import config from 'virtual:generated-config';
196
+ export const c = config;
197
+ EOF
198
+ EXIT_CODE=0
199
+ $NODE "$DEP_VERIFY" "$TMP9/proto.ts" >/dev/null 2>&1 || EXIT_CODE=$?
200
+ if [ "$EXIT_CODE" = "0" ]; then
201
+ pass "ignores protocol specifiers (virtual:, https:)"
202
+ else
203
+ fail_case "protocol specifier" "expected exit 0, got $EXIT_CODE"
204
+ fi
205
+
206
+ # ── Directory scan: flags src/, skips node_modules/ (production invocation) ──
207
+ TMPD=$(mktmp)
208
+ cat > "$TMPD/package.json" <<'EOF'
209
+ { "name": "fixture", "dependencies": { "react": "^19.0.0" } }
210
+ EOF
211
+ mkdir -p "$TMPD/src" "$TMPD/node_modules/some-pkg"
212
+ echo "import { useState } from 'react';" > "$TMPD/src/good.ts"
213
+ echo "import { z } from 'ghost-pkg-in-src';" > "$TMPD/src/bad.ts"
214
+ echo "import { q } from 'ghost-inside-node-modules';" > "$TMPD/node_modules/some-pkg/index.ts"
215
+ EXIT_CODE=0
216
+ OUT=$($NODE "$DEP_VERIFY" "$TMPD" 2>&1) || EXIT_CODE=$?
217
+ if [ "$EXIT_CODE" = "1" ] && echo "$OUT" | grep -q "ghost-pkg-in-src" && ! echo "$OUT" | grep -q "ghost-inside-node-modules"; then
218
+ pass "directory scan flags src/ and skips node_modules/"
219
+ else
220
+ fail_case "directory scan" "exit=$EXIT_CODE out=$(echo "$OUT" | head -c 160)"
221
+ fi
222
+
223
+ # ── --json flag produces JSON output (exit 1 + ok:false on findings) ──
224
+ EXIT_CODE=0
225
+ JSON_OUT=$($NODE "$DEP_VERIFY" --json "$TMP2/bad.tsx" 2>/dev/null) || EXIT_CODE=$?
226
+ if [ "$EXIT_CODE" = "1" ] \
227
+ && echo "$JSON_OUT" | head -1 | grep -qE "^[\{\[]" \
228
+ && echo "$JSON_OUT" | grep -q '"findings"' \
229
+ && echo "$JSON_OUT" | grep -q '"ok": false'; then
230
+ pass "--json produces JSON with ok:false and exits 1 on findings"
231
+ else
232
+ fail_case "--json output" "exit=$EXIT_CODE first line: '$(echo "$JSON_OUT" | head -c 80)'"
233
+ fi
234
+
235
+ # ── Missing path → invocation error (exit 2) ──────────────────────────
236
+ EXIT_CODE=0
237
+ $NODE "$DEP_VERIFY" /nonexistent/path/xyz >/dev/null 2>&1 || EXIT_CODE=$?
238
+ if [ "$EXIT_CODE" = "2" ]; then
239
+ pass "exits 2 on a missing path (invocation error)"
240
+ else
241
+ fail_case "missing path" "expected exit 2, got $EXIT_CODE"
242
+ fi
243
+
244
+ echo ""
245
+ echo "=== Results: $PASS passed, $FAIL failed ==="
246
+
247
+ [ "$FAIL" = "0" ]
@@ -494,6 +494,103 @@ else
494
494
  fi
495
495
  rm -rf "$TMP" "$QH"
496
496
 
497
+ # --- pre-deploy-gate: dependency-verification gate (dep-verify.mjs wired into ship) ---
498
+ # Build a tmp QUALIA_HOME carrying bin/dep-verify.mjs so the gate can find it.
499
+ DEP_SRC="$(cd "$(dirname "$0")/../bin" && pwd)/dep-verify.mjs"
500
+
501
+ # Hallucinated import (undeclared + not installed) → blocked with diagnostic
502
+ TMP=$(mktemp -d)
503
+ QH=$(mktemp -d)
504
+ mkdir -p "$QH/bin" "$TMP/app"
505
+ cp "$DEP_SRC" "$QH/bin/dep-verify.mjs"
506
+ echo '{"name":"x","dependencies":{}}' > "$TMP/package.json"
507
+ echo "import { z } from 'totally-made-up-pkg-xyz';" > "$TMP/app/page.tsx"
508
+ OUT=$( (cd "$TMP" && QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
509
+ RC=$?
510
+ if [ "$RC" -eq 2 ] && echo "$OUT" | grep -qi "hallucinated"; then
511
+ echo " ✓ hallucinated import → blocked by dependency gate (exit 2)"
512
+ PASS=$((PASS + 1))
513
+ else
514
+ echo " ✗ hallucinated import → dependency block (exit=$RC out=$OUT)"
515
+ FAIL=$((FAIL + 1))
516
+ fi
517
+ rm -rf "$TMP" "$QH"
518
+
519
+ # Clean file (no external imports) → dependency gate passes → exit 0
520
+ TMP=$(mktemp -d)
521
+ QH=$(mktemp -d)
522
+ mkdir -p "$QH/bin" "$TMP/app"
523
+ cp "$DEP_SRC" "$QH/bin/dep-verify.mjs"
524
+ echo '{"name":"x","dependencies":{}}' > "$TMP/package.json"
525
+ echo 'export default function P(){return null;}' > "$TMP/app/page.tsx"
526
+ OUT=$( (cd "$TMP" && QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
527
+ RC=$?
528
+ if [ "$RC" -eq 0 ] && echo "$OUT" | grep -q "Dependencies"; then
529
+ echo " ✓ clean imports → dependency gate passes (exit 0)"
530
+ PASS=$((PASS + 1))
531
+ else
532
+ echo " ✗ clean imports → dependency gate passes (exit=$RC out=$OUT)"
533
+ FAIL=$((FAIL + 1))
534
+ fi
535
+ rm -rf "$TMP" "$QH"
536
+
537
+ # QUALIA_SKIP_DEPCHECK=1 by non-OWNER → blocked (OWNER-only escape)
538
+ TMP=$(mktemp -d)
539
+ QH=$(mktemp -d)
540
+ mkdir -p "$QH/bin" "$TMP/app"
541
+ cp "$DEP_SRC" "$QH/bin/dep-verify.mjs"
542
+ echo '{"role":"EMPLOYEE"}' > "$QH/.qualia-config.json"
543
+ echo '{"name":"x","dependencies":{}}' > "$TMP/package.json"
544
+ echo "import { z } from 'totally-made-up-pkg-xyz';" > "$TMP/app/page.tsx"
545
+ OUT=$( (cd "$TMP" && QUALIA_SKIP_DEPCHECK=1 QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
546
+ RC=$?
547
+ if [ "$RC" -eq 2 ] && echo "$OUT" | grep -q "OWNER-only"; then
548
+ echo " ✓ QUALIA_SKIP_DEPCHECK by non-OWNER → blocked (OWNER-only)"
549
+ PASS=$((PASS + 1))
550
+ else
551
+ echo " ✗ QUALIA_SKIP_DEPCHECK non-OWNER block (exit=$RC out=$OUT)"
552
+ FAIL=$((FAIL + 1))
553
+ fi
554
+ rm -rf "$TMP" "$QH"
555
+
556
+ # QUALIA_SKIP_DEPCHECK=1 by OWNER → allowed through (the escape valve works)
557
+ TMP=$(mktemp -d)
558
+ QH=$(mktemp -d)
559
+ mkdir -p "$QH/bin" "$TMP/app"
560
+ cp "$DEP_SRC" "$QH/bin/dep-verify.mjs"
561
+ echo '{"role":"OWNER"}' > "$QH/.qualia-config.json"
562
+ echo '{"name":"x","dependencies":{}}' > "$TMP/package.json"
563
+ echo "import { z } from 'totally-made-up-pkg-xyz';" > "$TMP/app/page.tsx"
564
+ OUT=$( (cd "$TMP" && QUALIA_SKIP_DEPCHECK=1 QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
565
+ RC=$?
566
+ if [ "$RC" -eq 0 ] && echo "$OUT" | grep -qi "Dependency check skipped"; then
567
+ echo " ✓ QUALIA_SKIP_DEPCHECK by OWNER → allowed (escape valve works)"
568
+ PASS=$((PASS + 1))
569
+ else
570
+ echo " ✗ QUALIA_SKIP_DEPCHECK OWNER allow (exit=$RC out=$OUT)"
571
+ FAIL=$((FAIL + 1))
572
+ fi
573
+ rm -rf "$TMP" "$QH"
574
+
575
+ # Fail-closed: a crashing/non-completing dep-verify must BLOCK, not pass
576
+ TMP=$(mktemp -d)
577
+ QH=$(mktemp -d)
578
+ mkdir -p "$QH/bin" "$TMP/app"
579
+ # A stub scanner that exits 2 (invocation error) — must be treated as FAIL.
580
+ printf '#!/usr/bin/env node\nprocess.exit(2);\n' > "$QH/bin/dep-verify.mjs"
581
+ echo '{"name":"x","dependencies":{}}' > "$TMP/package.json"
582
+ echo 'export default function P(){return null;}' > "$TMP/app/page.tsx"
583
+ OUT=$( (cd "$TMP" && QUALIA_HOME="$QH" $NODE "$HOOKS_DIR/pre-deploy-gate.js") 2>&1 )
584
+ RC=$?
585
+ if [ "$RC" -eq 2 ] && echo "$OUT" | grep -qi "did not complete"; then
586
+ echo " ✓ dep-verify non-completion → blocked, not silently passed (fail-closed)"
587
+ PASS=$((PASS + 1))
588
+ else
589
+ echo " ✗ dep-verify fail-closed (exit=$RC out=$OUT)"
590
+ FAIL=$((FAIL + 1))
591
+ fi
592
+ rm -rf "$TMP" "$QH"
593
+
497
594
  # Scanner absent (older install) → gate skips silently → exit 0
498
595
  TMP=$(mktemp -d)
499
596
  QH=$(mktemp -d)
@@ -124,12 +124,13 @@ else
124
124
  fi
125
125
 
126
126
  if [ -d "$HOME_DIR/.claude/hooks" ] \
127
- && [ "$(find "$HOME_DIR/.claude/hooks" -maxdepth 1 -name '*.js' | wc -l | tr -d ' ')" = "15" ] \
127
+ && [ "$(find "$HOME_DIR/.claude/hooks" -maxdepth 1 -name '*.js' | wc -l | tr -d ' ')" = "16" ] \
128
128
  && [ -f "$HOME_DIR/.claude/hooks/fawzi-approval-guard.js" ] \
129
129
  && [ -f "$HOME_DIR/.claude/hooks/pre-compact.js" ] \
130
130
  && [ -f "$HOME_DIR/.claude/hooks/usage-capture.js" ] \
131
- && [ -f "$HOME_DIR/.claude/hooks/task-write-guard.js" ]; then
132
- pass "packaged install has 15 hooks including task-write-guard (v6.13)"
131
+ && [ -f "$HOME_DIR/.claude/hooks/task-write-guard.js" ] \
132
+ && [ -f "$HOME_DIR/.claude/hooks/secret-guard.js" ]; then
133
+ pass "packaged install has 16 hooks including task-write-guard + secret-guard"
133
134
  else
134
135
  fail_case "packaged hook set mismatch"
135
136
  fi