qualia-framework 7.0.0 → 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.
- package/CHANGELOG.md +58 -0
- package/bin/cli.js +6 -1
- package/bin/dep-verify.mjs +328 -0
- package/bin/install.js +9 -0
- package/bin/recall.js +12 -1
- package/bin/report-payload.js +4 -1
- package/bin/runtime-manifest.js +1 -0
- package/bin/state.js +11 -4
- package/bin/trust-score.js +1 -1
- package/hooks/pre-deploy-gate.js +54 -3
- package/hooks/secret-guard.js +162 -0
- package/package.json +4 -1
- package/skills/qualia-build/SKILL.md +8 -1
- package/skills/qualia-recall/SKILL.md +6 -1
- package/skills/qualia-ship/SKILL.md +2 -1
- package/skills/qualia-verify/SKILL.md +11 -4
- package/tests/bin.test.sh +2 -2
- package/tests/dep-verify.test.sh +247 -0
- package/tests/hooks.test.sh +97 -0
- package/tests/install-smoke.test.sh +4 -3
- package/tests/lib.test.sh +4 -4
- package/tests/recall.test.sh +12 -11
- package/tests/run-all.sh +3 -0
- package/tests/runner.js +2 -2
- package/tests/runtime-parity.test.sh +62 -0
- package/tests/secret-guard.test.sh +92 -0
- package/tests/state.test.sh +35 -0
|
@@ -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.
|
|
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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: qualia-recall
|
|
3
|
-
description: "Recall curated prior lessons from the Qualia memory substrate — the knowledge layer + the qualia-memory vault
|
|
3
|
+
description: "OWNER-ONLY. Recall curated prior lessons from the Qualia memory substrate — the knowledge layer + the qualia-memory vault. The read side of /qualia-learn. Only the OWNER (Fawzi) can run it; recall.js refuses any other role. Triggers (OWNER only): 'recall', 'have we done this before', 'what did we learn about X', 'prior art', 'any notes on', 'check the vault'."
|
|
4
4
|
allowed-tools:
|
|
5
5
|
- Read
|
|
6
6
|
- Grep
|
|
@@ -9,6 +9,11 @@ allowed-tools:
|
|
|
9
9
|
|
|
10
10
|
# /qualia-recall — Recall Knowledge
|
|
11
11
|
|
|
12
|
+
> **OWNER-only.** `recall.js` resolves the install role and **refuses any role
|
|
13
|
+
> other than `OWNER`** (exit 3). This is a deterministic gate, not a convention —
|
|
14
|
+
> do not run it for an employee. Employees still have the read-only memory MCP for
|
|
15
|
+
> ALL_ROLES wiki content; the curated cross-project recall is the OWNER's surface.
|
|
16
|
+
|
|
12
17
|
The **read side** of memory. `/qualia-learn` writes; `/qualia-recall` reads. One
|
|
13
18
|
query, both stores:
|
|
14
19
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
499
|
-
pass "
|
|
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" ]
|
package/tests/hooks.test.sh
CHANGED
|
@@ -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 ' ')" = "
|
|
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" ]
|
|
132
|
-
|
|
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
|