qualia-framework-v2 2.6.0 → 2.8.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/README.md +14 -6
- package/bin/cli.js +14 -6
- package/bin/install.js +34 -31
- package/bin/qualia-ui.js +6 -3
- package/bin/statusline.js +202 -0
- package/hooks/auto-update.js +92 -0
- package/hooks/block-env-edit.js +30 -0
- package/hooks/branch-guard.js +47 -0
- package/hooks/migration-guard.js +60 -0
- package/hooks/pre-compact.js +32 -0
- package/hooks/pre-deploy-gate.js +110 -0
- package/hooks/pre-push.js +33 -0
- package/hooks/session-start.js +84 -0
- package/package.json +3 -4
- package/skills/qualia-skill-new/SKILL.md +24 -5
- package/tests/hooks.test.sh +107 -55
- package/tests/state.test.sh +398 -0
- package/hooks/auto-update.sh +0 -56
- package/hooks/block-env-edit.sh +0 -11
- package/hooks/branch-guard.sh +0 -27
- package/hooks/migration-guard.sh +0 -43
- package/hooks/pre-compact.sh +0 -11
- package/hooks/pre-deploy-gate.sh +0 -50
- package/hooks/pre-push.sh +0 -28
- package/hooks/session-start.sh +0 -35
- package/statusline.sh +0 -93
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/migration-guard.js — catch dangerous SQL patterns in migrations.
|
|
3
|
+
// PreToolUse hook on Edit/Write tool calls. Reads tool input as JSON on stdin.
|
|
4
|
+
// Exits 2 to BLOCK. Exits 0 to allow.
|
|
5
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
|
|
9
|
+
function readInput() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const input = readInput();
|
|
19
|
+
const ti = input.tool_input || {};
|
|
20
|
+
const file = String(ti.file_path || "").replace(/\\/g, "/");
|
|
21
|
+
const content = String(ti.content || ti.new_string || "");
|
|
22
|
+
|
|
23
|
+
// Only inspect migration/SQL files
|
|
24
|
+
if (!/migration|migrate|\.sql$/i.test(file)) {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const errors = [];
|
|
29
|
+
|
|
30
|
+
// DROP TABLE without IF EXISTS
|
|
31
|
+
if (/DROP\s+TABLE/i.test(content) && !/IF\s+EXISTS/i.test(content)) {
|
|
32
|
+
errors.push("DROP TABLE without IF EXISTS");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// DELETE without WHERE
|
|
36
|
+
if (/DELETE\s+FROM/i.test(content) && !/WHERE/i.test(content)) {
|
|
37
|
+
errors.push("DELETE FROM without WHERE clause");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// TRUNCATE (almost always wrong in migrations)
|
|
41
|
+
if (/TRUNCATE/i.test(content)) {
|
|
42
|
+
errors.push("TRUNCATE detected — are you sure?");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// CREATE TABLE without RLS
|
|
46
|
+
if (/CREATE\s+TABLE/i.test(content) && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(content)) {
|
|
47
|
+
errors.push("CREATE TABLE without ENABLE ROW LEVEL SECURITY");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (errors.length > 0) {
|
|
51
|
+
console.log("◆ Migration guard — dangerous patterns found:");
|
|
52
|
+
for (const e of errors) {
|
|
53
|
+
console.log(` ✗ ${e}`);
|
|
54
|
+
}
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log("Fix these before proceeding. If intentional, ask Fawzi to approve.");
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
process.exit(0);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/pre-compact.js — commit STATE.md before context compaction.
|
|
3
|
+
// PreCompact hook. Silent on failure — context compaction must never be blocked.
|
|
4
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawnSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
const STATE_FILE = path.join(".planning", "STATE.md");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
14
|
+
console.log("QUALIA: Saving state before compaction...");
|
|
15
|
+
// Check if STATE.md has uncommitted changes
|
|
16
|
+
const diff = spawnSync("git", ["diff", "--name-only", STATE_FILE], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
timeout: 3000,
|
|
19
|
+
});
|
|
20
|
+
if ((diff.stdout || "").includes("STATE.md")) {
|
|
21
|
+
spawnSync("git", ["add", STATE_FILE], { timeout: 3000 });
|
|
22
|
+
spawnSync("git", ["commit", "-m", "state: pre-compaction save"], {
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Silent — never block compaction
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
process.exit(0);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/pre-deploy-gate.js — quality gates before production deploy.
|
|
3
|
+
// PreToolUse hook on `vercel --prod*` commands. Runs tsc, lint, tests, build,
|
|
4
|
+
// then scans for service_role leaks in client code.
|
|
5
|
+
// Exits 1 to BLOCK deploy. Exits 0 to allow.
|
|
6
|
+
// Cross-platform (Windows/macOS/Linux). No `grep` or `find` — pure Node.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { spawnSync } = require("child_process");
|
|
11
|
+
|
|
12
|
+
function runGate(label, cmd, args, { required = true } = {}) {
|
|
13
|
+
const r = spawnSync(cmd, args, {
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
timeout: 180000,
|
|
16
|
+
shell: process.platform === "win32",
|
|
17
|
+
});
|
|
18
|
+
if (r.status === 0) {
|
|
19
|
+
console.log(` ✓ ${label}`);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
if (required) {
|
|
23
|
+
console.log(`BLOCKED: ${label} errors. Fix before deploying.`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasScript(name) {
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
|
32
|
+
return pkg.scripts && typeof pkg.scripts[name] === "string";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function walk(dir, out = []) {
|
|
39
|
+
if (!fs.existsSync(dir)) return out;
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
48
|
+
const full = path.join(dir, e.name);
|
|
49
|
+
if (e.isDirectory()) {
|
|
50
|
+
walk(full, out);
|
|
51
|
+
} else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name)) {
|
|
52
|
+
out.push(full);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scanServiceRoleLeaks() {
|
|
59
|
+
const roots = ["app", "components", "src", "pages", "lib"];
|
|
60
|
+
const leaks = [];
|
|
61
|
+
for (const root of roots) {
|
|
62
|
+
for (const file of walk(root)) {
|
|
63
|
+
// Skip server-only files (convention: *.server.ts, server/ dirs)
|
|
64
|
+
if (/\.server\.|[\\/]server[\\/]/.test(file)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(file, "utf8");
|
|
67
|
+
if (/service_role/.test(content)) {
|
|
68
|
+
leaks.push(file);
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return leaks;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log("◆ Pre-deploy gate...");
|
|
77
|
+
|
|
78
|
+
// TypeScript
|
|
79
|
+
if (fs.existsSync("tsconfig.json")) {
|
|
80
|
+
runGate("TypeScript", "npx", ["tsc", "--noEmit"]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Lint
|
|
84
|
+
if (hasScript("lint")) {
|
|
85
|
+
runGate("Lint", "npm", ["run", "lint"]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Tests
|
|
89
|
+
if (hasScript("test")) {
|
|
90
|
+
runGate("Tests", "npm", ["test"]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build
|
|
94
|
+
if (hasScript("build")) {
|
|
95
|
+
runGate("Build", "npm", ["run", "build"]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Security: no service_role in client code
|
|
99
|
+
const leaks = scanServiceRoleLeaks();
|
|
100
|
+
if (leaks.length > 0) {
|
|
101
|
+
console.log("BLOCKED: service_role found in client code. Remove before deploying.");
|
|
102
|
+
for (const f of leaks.slice(0, 10)) {
|
|
103
|
+
console.log(` ✗ ${f}`);
|
|
104
|
+
}
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
console.log(" ✓ Security");
|
|
108
|
+
console.log("◆ All gates passed.");
|
|
109
|
+
|
|
110
|
+
process.exit(0);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/pre-push.js — update tracking.json with last commit + timestamp.
|
|
3
|
+
// PreToolUse hook on `git push*` commands. state.js handles phase/status sync;
|
|
4
|
+
// this just stamps the file so the ERP sees fresh commit info on every push.
|
|
5
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const TRACKING = path.join(".planning", "tracking.json");
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (fs.existsSync(TRACKING)) {
|
|
15
|
+
const r = spawnSync("git", ["log", "--oneline", "-1", "--format=%h"], {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
timeout: 3000,
|
|
18
|
+
});
|
|
19
|
+
const lastCommit = ((r.stdout || "").trim());
|
|
20
|
+
const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
21
|
+
|
|
22
|
+
const t = JSON.parse(fs.readFileSync(TRACKING, "utf8"));
|
|
23
|
+
if (lastCommit) t.last_commit = lastCommit;
|
|
24
|
+
t.last_updated = now;
|
|
25
|
+
fs.writeFileSync(TRACKING, JSON.stringify(t, null, 2) + "\n");
|
|
26
|
+
|
|
27
|
+
spawnSync("git", ["add", TRACKING], { timeout: 3000 });
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
process.stderr.write(`WARNING: tracking sync failed: ${err.message}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
process.exit(0);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/session-start.js — branded context panel on session start.
|
|
3
|
+
// Cross-platform (Windows/macOS/Linux). Zero shell dependencies.
|
|
4
|
+
//
|
|
5
|
+
// CRITICAL: this hook must NEVER exit non-zero. Claude Code treats any non-zero
|
|
6
|
+
// exit from a SessionStart hook as a "hook error" and shows a red banner. The
|
|
7
|
+
// banner is purely informational — we'd rather render nothing than block a
|
|
8
|
+
// session. Every call is wrapped in try/catch and we always exit 0 at the end.
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
const { spawnSync } = require("child_process");
|
|
14
|
+
|
|
15
|
+
const HOME = os.homedir();
|
|
16
|
+
const UI = path.join(HOME, ".claude", "bin", "qualia-ui.js");
|
|
17
|
+
const STATE_FILE = path.join(".planning", "STATE.md");
|
|
18
|
+
const CONTINUE_HERE = ".continue-here.md";
|
|
19
|
+
|
|
20
|
+
function runUi(...args) {
|
|
21
|
+
if (!fs.existsSync(UI)) return;
|
|
22
|
+
try {
|
|
23
|
+
spawnSync(process.execPath, [UI, ...args], {
|
|
24
|
+
stdio: "inherit",
|
|
25
|
+
timeout: 3000,
|
|
26
|
+
});
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getNextCommand() {
|
|
31
|
+
const stateJs = path.join(HOME, ".claude", "bin", "state.js");
|
|
32
|
+
if (!fs.existsSync(stateJs)) return "";
|
|
33
|
+
try {
|
|
34
|
+
const r = spawnSync(process.execPath, [stateJs, "check"], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
timeout: 3000,
|
|
37
|
+
});
|
|
38
|
+
if (!r.stdout) return "";
|
|
39
|
+
const j = JSON.parse(r.stdout);
|
|
40
|
+
return j.next_command || "";
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fallbackText() {
|
|
47
|
+
// If qualia-ui.js is missing, emit plain text. Keeps the session informative
|
|
48
|
+
// even on a broken install.
|
|
49
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
50
|
+
try {
|
|
51
|
+
const content = fs.readFileSync(STATE_FILE, "utf8");
|
|
52
|
+
const phase = (content.match(/^Phase:\s*(.+)$/m) || [])[1] || "—";
|
|
53
|
+
const status = (content.match(/^Status:\s*(.+)$/m) || [])[1] || "—";
|
|
54
|
+
console.log(`QUALIA: Project loaded. Phase: ${phase.trim()} | Status: ${status.trim()}`);
|
|
55
|
+
console.log("QUALIA: Run /qualia for next step.");
|
|
56
|
+
} catch {
|
|
57
|
+
console.log("QUALIA: Project detected but STATE.md could not be read.");
|
|
58
|
+
}
|
|
59
|
+
} else if (fs.existsSync(CONTINUE_HERE)) {
|
|
60
|
+
console.log("QUALIA: Handoff file found. Read .continue-here.md to resume.");
|
|
61
|
+
} else {
|
|
62
|
+
console.log("QUALIA: No project detected. Run /qualia-new to start.");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (!fs.existsSync(UI)) {
|
|
68
|
+
fallbackText();
|
|
69
|
+
} else if (fs.existsSync(STATE_FILE)) {
|
|
70
|
+
runUi("banner", "router");
|
|
71
|
+
const next = getNextCommand();
|
|
72
|
+
if (next) runUi("info", `Run ${next} to continue`);
|
|
73
|
+
} else if (fs.existsSync(CONTINUE_HERE)) {
|
|
74
|
+
runUi("banner", "router");
|
|
75
|
+
runUi("warn", "Handoff found — read .continue-here.md to resume");
|
|
76
|
+
} else {
|
|
77
|
+
runUi("banner", "router");
|
|
78
|
+
runUi("info", "No project detected. Run /qualia-new to start.");
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Deliberately silent — hook must never fail
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework-v2",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualia-framework-v2": "./bin/cli.js"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/qualia-solutions/qualia-framework-v2#readme",
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "bash tests/hooks.test.sh"
|
|
26
|
+
"test": "bash tests/hooks.test.sh && bash tests/state.test.sh"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"bin/",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"templates/",
|
|
35
35
|
"tests/",
|
|
36
36
|
"CLAUDE.md",
|
|
37
|
-
"guide.md"
|
|
38
|
-
"statusline.sh"
|
|
37
|
+
"guide.md"
|
|
39
38
|
],
|
|
40
39
|
"engines": {
|
|
41
40
|
"node": ">=18"
|
|
@@ -29,9 +29,27 @@ options:
|
|
|
29
29
|
description: "This is a subagent role, not a slash command. Creates agents/{name}.md."
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
### 1a. Resolve framework directory
|
|
33
|
+
|
|
34
|
+
If the user chose **Framework skill** or a framework-scoped **Agent**, resolve `${FRAMEWORK_DIR}` — the checkout path of this user's qualia-framework-v2 repo — BEFORE computing any target paths. Never hardcode `/home/<user>/...`; different teammates and operating systems have different paths.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Priority order: env var → git detection → ask user
|
|
38
|
+
FRAMEWORK_DIR="${QUALIA_FRAMEWORK_DIR:-}"
|
|
39
|
+
if [ -z "$FRAMEWORK_DIR" ] && git -C . rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
40
|
+
ORIGIN=$(git -C . config --get remote.origin.url 2>/dev/null)
|
|
41
|
+
case "$ORIGIN" in
|
|
42
|
+
*qualia-framework-v2*) FRAMEWORK_DIR=$(git -C . rev-parse --show-toplevel) ;;
|
|
43
|
+
esac
|
|
44
|
+
fi
|
|
45
|
+
echo "${FRAMEWORK_DIR:-UNRESOLVED}"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If the command prints `UNRESOLVED`, ask the user: *"Where is your qualia-framework-v2 checkout? (absolute path, or type 'local' to save only to ~/.claude/)"*. If they type `local`, downgrade the scope to Local. Otherwise store the answer as `${FRAMEWORK_DIR}` for the rest of the session.
|
|
49
|
+
|
|
50
|
+
**Framework** → target: `${FRAMEWORK_DIR}/skills/{name}/SKILL.md`
|
|
33
51
|
**Local** → target: `~/.claude/skills/{name}/SKILL.md`
|
|
34
|
-
**Agent** → target:
|
|
52
|
+
**Agent** → target: `${FRAMEWORK_DIR}/agents/{name}.md` (framework) or `~/.claude/agents/{name}.md` (local)
|
|
35
53
|
|
|
36
54
|
### 2. Gather Requirements
|
|
37
55
|
|
|
@@ -119,10 +137,11 @@ Fix any ambiguity the test agent found.
|
|
|
119
137
|
|
|
120
138
|
```bash
|
|
121
139
|
# Framework skill — copy to local .claude for immediate testing
|
|
122
|
-
|
|
140
|
+
mkdir -p ~/.claude/skills/{name}
|
|
141
|
+
cp "${FRAMEWORK_DIR}/skills/{name}/SKILL.md" ~/.claude/skills/{name}/SKILL.md
|
|
123
142
|
|
|
124
143
|
# Verify it parses
|
|
125
|
-
node -e "const fs=require('fs');const c=fs.readFileSync('
|
|
144
|
+
node -e "const fs=require('fs');const os=require('os');const path=require('path');const c=fs.readFileSync(path.join(os.homedir(),'.claude/skills/{name}/SKILL.md'),'utf8');if(!c.includes('---'))throw new Error('missing frontmatter');if(!c.match(/^name:\s*\S/m))throw new Error('missing name');if(!c.match(/^description:\s*\S/m))throw new Error('missing description');console.log('OK')"
|
|
126
145
|
```
|
|
127
146
|
|
|
128
147
|
### 7. Commit (framework skills only)
|
|
@@ -131,7 +150,7 @@ Do NOT commit unless the user explicitly says "commit" or "ship it".
|
|
|
131
150
|
|
|
132
151
|
When they do:
|
|
133
152
|
```bash
|
|
134
|
-
cd
|
|
153
|
+
cd "${FRAMEWORK_DIR}"
|
|
135
154
|
git add skills/{name}/
|
|
136
155
|
git commit -m "feat: add /{name} skill"
|
|
137
156
|
```
|
package/tests/hooks.test.sh
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# Qualia Framework v2 — Hook Tests
|
|
2
|
+
# Qualia Framework v2 — Hook Tests (cross-platform Node.js hooks)
|
|
3
3
|
# Run: bash tests/hooks.test.sh
|
|
4
4
|
|
|
5
5
|
PASS=0
|
|
6
6
|
FAIL=0
|
|
7
|
-
HOOKS_DIR
|
|
7
|
+
# Resolve HOOKS_DIR to an ABSOLUTE path so `cd` inside subshells doesn't break it.
|
|
8
|
+
HOOKS_DIR="$(cd "$(dirname "$0")/../hooks" && pwd)"
|
|
9
|
+
NODE="${NODE:-node}"
|
|
8
10
|
|
|
9
11
|
assert_exit() {
|
|
10
12
|
local name="$1" expected="$2" actual="$3"
|
|
@@ -17,139 +19,189 @@ assert_exit() {
|
|
|
17
19
|
fi
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
echo "=== Hook Tests ==="
|
|
22
|
+
echo "=== Hook Tests (Node.js) ==="
|
|
21
23
|
echo ""
|
|
22
24
|
|
|
23
|
-
# ---
|
|
25
|
+
# --- All hooks are syntactically valid Node.js ---
|
|
26
|
+
echo "syntax:"
|
|
27
|
+
for f in "$HOOKS_DIR"/*.js; do
|
|
28
|
+
if $NODE -c "$f" 2>/dev/null; then
|
|
29
|
+
echo " ✓ $(basename "$f")"
|
|
30
|
+
PASS=$((PASS + 1))
|
|
31
|
+
else
|
|
32
|
+
echo " ✗ $(basename "$f")"
|
|
33
|
+
FAIL=$((FAIL + 1))
|
|
34
|
+
fi
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
# --- block-env-edit.js ---
|
|
38
|
+
echo ""
|
|
24
39
|
echo "block-env-edit:"
|
|
25
40
|
|
|
26
|
-
echo '{"tool_input":{"file_path":".env.local"}}' |
|
|
41
|
+
echo '{"tool_input":{"file_path":".env.local"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
27
42
|
assert_exit "blocks .env.local" 2 $?
|
|
28
43
|
|
|
29
|
-
echo '{"tool_input":{"file_path":".env.production"}}' |
|
|
44
|
+
echo '{"tool_input":{"file_path":".env.production"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
30
45
|
assert_exit "blocks .env.production" 2 $?
|
|
31
46
|
|
|
32
|
-
echo '{"tool_input":{"file_path":".env"}}' |
|
|
47
|
+
echo '{"tool_input":{"file_path":".env"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
33
48
|
assert_exit "blocks .env" 2 $?
|
|
34
49
|
|
|
35
|
-
|
|
50
|
+
# Windows-style path with backslashes (normalized by the hook)
|
|
51
|
+
echo '{"tool_input":{"file_path":"C:\\project\\.env.local"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
52
|
+
assert_exit "blocks windows .env.local" 2 $?
|
|
53
|
+
|
|
54
|
+
echo '{"tool_input":{"file_path":"src/app.tsx"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
36
55
|
assert_exit "allows src/app.tsx" 0 $?
|
|
37
56
|
|
|
38
|
-
echo '{"tool_input":{"file_path":"components/Footer.tsx"}}' |
|
|
57
|
+
echo '{"tool_input":{"file_path":"components/Footer.tsx"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
39
58
|
assert_exit "allows components/Footer.tsx" 0 $?
|
|
40
59
|
|
|
41
|
-
# --- migration-guard.
|
|
60
|
+
# --- migration-guard.js ---
|
|
42
61
|
echo ""
|
|
43
62
|
echo "migration-guard:"
|
|
44
63
|
|
|
45
|
-
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE users;"}}' |
|
|
64
|
+
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
46
65
|
assert_exit "blocks DROP TABLE without IF EXISTS" 2 $?
|
|
47
66
|
|
|
48
|
-
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE IF EXISTS old_users;"}}' |
|
|
67
|
+
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE IF EXISTS old_users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
49
68
|
assert_exit "allows DROP TABLE IF EXISTS" 0 $?
|
|
50
69
|
|
|
51
|
-
echo '{"tool_input":{"file_path":"migrations/002.sql","content":"DELETE FROM users;"}}' |
|
|
70
|
+
echo '{"tool_input":{"file_path":"migrations/002.sql","content":"DELETE FROM users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
52
71
|
assert_exit "blocks DELETE without WHERE" 2 $?
|
|
53
72
|
|
|
54
|
-
echo '{"tool_input":{"file_path":"migrations/003.sql","content":"TRUNCATE TABLE sessions;"}}' |
|
|
73
|
+
echo '{"tool_input":{"file_path":"migrations/003.sql","content":"TRUNCATE TABLE sessions;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
55
74
|
assert_exit "blocks TRUNCATE" 2 $?
|
|
56
75
|
|
|
57
|
-
echo '{"tool_input":{"file_path":"migrations/004.sql","content":"CREATE TABLE users (id uuid);"}}' |
|
|
76
|
+
echo '{"tool_input":{"file_path":"migrations/004.sql","content":"CREATE TABLE users (id uuid);"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
58
77
|
assert_exit "blocks CREATE TABLE without RLS" 2 $?
|
|
59
78
|
|
|
60
|
-
echo '{"tool_input":{"file_path":"migrations/005.sql","content":"ALTER TABLE users ADD COLUMN email text;"}}' |
|
|
79
|
+
echo '{"tool_input":{"file_path":"migrations/005.sql","content":"ALTER TABLE users ADD COLUMN email text;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
61
80
|
assert_exit "allows safe ALTER TABLE" 0 $?
|
|
62
81
|
|
|
63
|
-
echo '{"tool_input":{"file_path":"src/app.tsx","content":"DROP TABLE users;"}}' |
|
|
82
|
+
echo '{"tool_input":{"file_path":"src/app.tsx","content":"DROP TABLE users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
64
83
|
assert_exit "skips non-migration files" 0 $?
|
|
65
84
|
|
|
66
|
-
# --- branch-guard.
|
|
85
|
+
# --- branch-guard.js (grep-based — full run needs git + real config) ---
|
|
67
86
|
echo ""
|
|
68
87
|
echo "branch-guard:"
|
|
69
88
|
|
|
70
|
-
if
|
|
71
|
-
echo " ✓
|
|
89
|
+
if grep -q '.qualia-config.json' "$HOOKS_DIR/branch-guard.js"; then
|
|
90
|
+
echo " ✓ reads role from .qualia-config.json"
|
|
72
91
|
PASS=$((PASS + 1))
|
|
73
92
|
else
|
|
74
|
-
echo " ✗
|
|
93
|
+
echo " ✗ not reading from .qualia-config.json"
|
|
75
94
|
FAIL=$((FAIL + 1))
|
|
76
95
|
fi
|
|
77
96
|
|
|
78
|
-
if grep -q '
|
|
79
|
-
echo " ✓ checks
|
|
97
|
+
if grep -q 'branch --show-current' "$HOOKS_DIR/branch-guard.js"; then
|
|
98
|
+
echo " ✓ checks current git branch"
|
|
80
99
|
PASS=$((PASS + 1))
|
|
81
100
|
else
|
|
82
|
-
echo " ✗ missing
|
|
101
|
+
echo " ✗ missing branch check"
|
|
83
102
|
FAIL=$((FAIL + 1))
|
|
84
103
|
fi
|
|
85
104
|
|
|
86
|
-
if grep -q '
|
|
87
|
-
echo " ✓
|
|
105
|
+
if grep -q 'OWNER' "$HOOKS_DIR/branch-guard.js"; then
|
|
106
|
+
echo " ✓ enforces OWNER role"
|
|
88
107
|
PASS=$((PASS + 1))
|
|
89
108
|
else
|
|
90
|
-
echo " ✗ missing
|
|
109
|
+
echo " ✗ missing OWNER check"
|
|
91
110
|
FAIL=$((FAIL + 1))
|
|
92
111
|
fi
|
|
93
112
|
|
|
94
|
-
# --- pre-push.
|
|
113
|
+
# --- pre-push.js ---
|
|
95
114
|
echo ""
|
|
96
115
|
echo "pre-push:"
|
|
97
116
|
|
|
98
|
-
if grep -q '
|
|
99
|
-
echo " ✓
|
|
117
|
+
if grep -q 'tracking.json' "$HOOKS_DIR/pre-push.js"; then
|
|
118
|
+
echo " ✓ updates tracking.json"
|
|
100
119
|
PASS=$((PASS + 1))
|
|
101
120
|
else
|
|
102
|
-
echo " ✗ missing
|
|
121
|
+
echo " ✗ missing tracking.json update"
|
|
103
122
|
FAIL=$((FAIL + 1))
|
|
104
123
|
fi
|
|
105
124
|
|
|
106
|
-
if grep -q '
|
|
107
|
-
echo " ✓
|
|
125
|
+
if grep -q 'last_commit' "$HOOKS_DIR/pre-push.js"; then
|
|
126
|
+
echo " ✓ stamps last_commit"
|
|
108
127
|
PASS=$((PASS + 1))
|
|
109
128
|
else
|
|
110
|
-
echo " ✗ missing
|
|
129
|
+
echo " ✗ missing last_commit stamp"
|
|
111
130
|
FAIL=$((FAIL + 1))
|
|
112
131
|
fi
|
|
113
132
|
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
echo " ✓ reads role from .qualia-config.json"
|
|
120
|
-
PASS=$((PASS + 1))
|
|
121
|
-
else
|
|
122
|
-
echo " ✗ not reading from .qualia-config.json"
|
|
123
|
-
FAIL=$((FAIL + 1))
|
|
124
|
-
fi
|
|
133
|
+
# Run pre-push.js in a dir with no tracking.json — must exit 0 cleanly
|
|
134
|
+
TMP=$(mktemp -d)
|
|
135
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/pre-push.js" >/dev/null 2>&1)
|
|
136
|
+
assert_exit "exits 0 with no tracking.json" 0 $?
|
|
137
|
+
rm -rf "$TMP"
|
|
125
138
|
|
|
126
|
-
# --- pre-deploy-gate.
|
|
139
|
+
# --- pre-deploy-gate.js ---
|
|
127
140
|
echo ""
|
|
128
141
|
echo "pre-deploy-gate:"
|
|
129
142
|
|
|
130
|
-
if
|
|
131
|
-
echo " ✓
|
|
143
|
+
if grep -q 'tsc' "$HOOKS_DIR/pre-deploy-gate.js"; then
|
|
144
|
+
echo " ✓ runs TypeScript check"
|
|
132
145
|
PASS=$((PASS + 1))
|
|
133
146
|
else
|
|
134
|
-
echo " ✗
|
|
147
|
+
echo " ✗ missing TypeScript check"
|
|
135
148
|
FAIL=$((FAIL + 1))
|
|
136
149
|
fi
|
|
137
150
|
|
|
138
|
-
if grep -q '
|
|
139
|
-
echo " ✓
|
|
151
|
+
if grep -q 'service_role' "$HOOKS_DIR/pre-deploy-gate.js"; then
|
|
152
|
+
echo " ✓ checks for service_role leaks"
|
|
140
153
|
PASS=$((PASS + 1))
|
|
141
154
|
else
|
|
142
|
-
echo " ✗ missing
|
|
155
|
+
echo " ✗ missing service_role check"
|
|
143
156
|
FAIL=$((FAIL + 1))
|
|
144
157
|
fi
|
|
145
158
|
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
# --- session-start.js — must exit 0 always ---
|
|
160
|
+
echo ""
|
|
161
|
+
echo "session-start:"
|
|
162
|
+
|
|
163
|
+
TMP=$(mktemp -d)
|
|
164
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/session-start.js" >/dev/null 2>&1)
|
|
165
|
+
assert_exit "exits 0 with no project" 0 $?
|
|
166
|
+
|
|
167
|
+
# Simulate a project with STATE.md
|
|
168
|
+
mkdir -p "$TMP/.planning"
|
|
169
|
+
cat > "$TMP/.planning/STATE.md" <<'EOF'
|
|
170
|
+
# Project State
|
|
171
|
+
Phase: 1 of 3 — Foundation
|
|
172
|
+
Status: setup
|
|
173
|
+
EOF
|
|
174
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/session-start.js" >/dev/null 2>&1)
|
|
175
|
+
assert_exit "exits 0 with STATE.md" 0 $?
|
|
176
|
+
rm -rf "$TMP"
|
|
177
|
+
|
|
178
|
+
# --- pre-compact.js ---
|
|
179
|
+
echo ""
|
|
180
|
+
echo "pre-compact:"
|
|
181
|
+
|
|
182
|
+
TMP=$(mktemp -d)
|
|
183
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/pre-compact.js" >/dev/null 2>&1)
|
|
184
|
+
assert_exit "exits 0 with no STATE.md" 0 $?
|
|
185
|
+
rm -rf "$TMP"
|
|
186
|
+
|
|
187
|
+
# --- auto-update.js ---
|
|
188
|
+
echo ""
|
|
189
|
+
echo "auto-update:"
|
|
190
|
+
|
|
191
|
+
TMP=$(mktemp -d)
|
|
192
|
+
mkdir -p "$TMP/.claude"
|
|
193
|
+
echo '{"code":"QS-FAWZI-01","version":"99.99.99"}' > "$TMP/.claude/.qualia-config.json"
|
|
194
|
+
HOME="$TMP" $NODE "$HOOKS_DIR/auto-update.js" >/dev/null 2>&1
|
|
195
|
+
assert_exit "exits 0 (fast path)" 0 $?
|
|
196
|
+
# Should now have cache file
|
|
197
|
+
if [ -f "$TMP/.claude/.qualia-last-update-check" ]; then
|
|
198
|
+
echo " ✓ writes cache timestamp"
|
|
148
199
|
PASS=$((PASS + 1))
|
|
149
200
|
else
|
|
150
|
-
echo " ✗ missing
|
|
201
|
+
echo " ✗ missing cache timestamp"
|
|
151
202
|
FAIL=$((FAIL + 1))
|
|
152
203
|
fi
|
|
204
|
+
rm -rf "$TMP"
|
|
153
205
|
|
|
154
206
|
echo ""
|
|
155
207
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|