qualia-framework 7.0.1 → 7.2.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 +76 -0
- package/bin/cli.js +6 -1
- package/bin/dep-verify.mjs +328 -0
- package/bin/install.js +15 -0
- package/bin/qualia-ui.js +186 -1
- package/bin/report-payload.js +4 -1
- package/bin/runtime-manifest.js +1 -0
- package/bin/state.js +11 -4
- package/bin/statusline.js +7 -1
- package/bin/trust-score.js +1 -1
- package/hooks/pre-deploy-gate.js +54 -3
- package/hooks/secret-guard.js +162 -0
- package/hooks/session-start.js +1 -0
- package/package.json +4 -1
- package/skills/qualia-build/SKILL.md +8 -1
- package/skills/qualia-report/SKILL.md +11 -0
- package/skills/qualia-ship/SKILL.md +17 -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/journey-spine.test.sh +171 -0
- package/tests/lib.test.sh +4 -4
- package/tests/run-all.sh +4 -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
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
> Note: git tags for historical versions were not retained; commit references are approximate
|
|
9
9
|
> and dates reflect commit history rather than npm publish timestamps.
|
|
10
10
|
|
|
11
|
+
## [7.2.0] - 2026-06-25 (journey spine — lifecycle UX for employees)
|
|
12
|
+
|
|
13
|
+
A cosmetic/UX layer that gives an employee a continuous sense of place and
|
|
14
|
+
momentum across the whole lifecycle — install → start → work → finish — reusing
|
|
15
|
+
the existing teal palette and glyph set. No new commands to learn; the framework
|
|
16
|
+
just shows "where am I, what's my forward motion, what's the one next move" at
|
|
17
|
+
every touchpoint.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **`qualia-ui.js spine`** — compact horizontal milestone ladder
|
|
21
|
+
(`M1 ●──M2 ◆──M3 ○`) with a caret aligned exactly beneath the current
|
|
22
|
+
milestone. Rendered by the session-start banner so the journey map is the
|
|
23
|
+
first thing an employee sees. Self-skips on single-milestone projects.
|
|
24
|
+
- **`qualia-ui.js onboard <name>`** — first-run mental-model card (Plan → Build
|
|
25
|
+
→ Verify → Ship + the one first move). Shown after install for non-OWNER
|
|
26
|
+
roles only.
|
|
27
|
+
- **`qualia-ui.js phase-complete <n> <name> [k=v…]`** — per-phase celebration
|
|
28
|
+
with a milestone progress bar and optional streak; wired into `/qualia-ship`.
|
|
29
|
+
- **`qualia-ui.js clockout <name> [k=v…]`** — end-of-day report card (shipped
|
|
30
|
+
counts, milestone %, streak); wired into `/qualia-report`. Every metric
|
|
31
|
+
self-omits when its data is unavailable — never fabricates.
|
|
32
|
+
- **`barTicks(done,total,width)`** — reusable `▰▱` micro-bar helper.
|
|
33
|
+
- **Always-visible progress in the statusline** — the phase pill now carries a
|
|
34
|
+
`▰▰▰▱▱` bar (`P3/5 ▰▰▰▱▱`) so forward motion is ambient while working.
|
|
35
|
+
|
|
36
|
+
### Tests
|
|
37
|
+
- New suite `journey-spine.test.sh` (33 cases): barTicks scaling/clamping, all
|
|
38
|
+
four cards, spine caret-alignment (exact column match), self-skip paths, and
|
|
39
|
+
the statusline bar. Full suite green: **28 shell suites + 179 node tests**.
|
|
40
|
+
|
|
41
|
+
## [7.1.0] - 2026-06-25 (harness gates — hallucinated deps, secrets, model routing)
|
|
42
|
+
|
|
43
|
+
Hardening pass: reviewed Google's "The New SDLC With Vibe Coding" whitepaper
|
|
44
|
+
against the framework, then ran a deep multi-agent audit of the result and fixed
|
|
45
|
+
what it surfaced. Theme: move enforcement out of model-read prose and into
|
|
46
|
+
deterministic gates.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- **`bin/dep-verify.mjs` — hallucinated/slopsquatted dependency gate.** Flags an
|
|
50
|
+
import only when its package is BOTH undeclared in `package.json` AND absent
|
|
51
|
+
from `node_modules` — the paper's #1 named AI-generated-code security failure
|
|
52
|
+
mode. A string/regex/comment-aware parser with statement-anchored matching
|
|
53
|
+
keeps false positives near zero. Wired into `/qualia-verify`, `/qualia-ship`,
|
|
54
|
+
and the `pre-deploy-gate` hook (OWNER-only `QUALIA_SKIP_DEPCHECK` escape).
|
|
55
|
+
- **`hooks/secret-guard.js` — commit-time secret gate.** Fail-closed PreToolUse
|
|
56
|
+
hook on `git commit*` that scans staged content for service_role keys, private
|
|
57
|
+
keys, AWS/OpenAI/GitHub tokens, and staged `.env` files; blocks (exit 2),
|
|
58
|
+
never prints the matched value, OWNER-only `QUALIA_SECRET_SKIP` escape.
|
|
59
|
+
Enforces two constitution non-negotiables that were prose-only. Registered on
|
|
60
|
+
both runtimes; added to `trust-score` REQUIRED_HOOKS.
|
|
61
|
+
- **Intelligent model routing.** `/qualia-build` builders default to `sonnet`
|
|
62
|
+
(escalate to frontier for architect / high-complexity / auth-payment-migration;
|
|
63
|
+
`haiku` for mechanical sweeps); `/qualia-verify` panel verifiers run on
|
|
64
|
+
`sonnet`, skeptic adjudication on frontier. The token-economy OpEx lever.
|
|
65
|
+
- **`tests/runtime-parity.test.sh`.** Asserts the Codex blocking-gate set is a
|
|
66
|
+
superset of the critical gates so Claude/Codex hook drift can't recur.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
- **Deploy gates now fail CLOSED.** `pre-deploy-gate` blocked only on exit 1, so a
|
|
70
|
+
scanner crash or timeout printed "✓" and deployed anyway; both the anti-slop and
|
|
71
|
+
dep-verify blocks now block on any non-clean outcome.
|
|
72
|
+
- **Codex gate parity.** `task-write-guard.js` (the off-contract-write gate) was
|
|
73
|
+
registered on Claude but omitted from the Codex hooks — so on Codex a builder
|
|
74
|
+
could write fabricated/out-of-scope files unblocked. Now registered on both.
|
|
75
|
+
- **A5 failed-status gap closure (`state.js`).** The A5 increment path sets status
|
|
76
|
+
`"failed"` on a failed verify (legacy keeps `"verified"`+fail); the gap-cycle
|
|
77
|
+
circuit breaker, the re-plan transition (`VALID_FROM`), the gap-cycle counter,
|
|
78
|
+
and `next_command` all only recognized `"verified"` — so the breaker never fired
|
|
79
|
+
(unbounded gap cycles), re-plan was rejected, and routing dead-ended at
|
|
80
|
+
`/qualia`. All four spots now recognize `"failed"`.
|
|
81
|
+
|
|
82
|
+
### Tests
|
|
83
|
+
- New suites: dep-verify, secret-guard, runtime-parity; A5 regression cases in
|
|
84
|
+
`state.test.sh`; pre-deploy-gate fail-closed + dep cases in `hooks.test.sh`.
|
|
85
|
+
- Full suite green: **27 shell suites + 179 node tests**.
|
|
86
|
+
|
|
11
87
|
## [7.0.1] - 2026-06-22 (/qualia-recall is OWNER-only)
|
|
12
88
|
|
|
13
89
|
### Changed — `/qualia-recall` restricted to OWNER
|
package/bin/cli.js
CHANGED
|
@@ -214,6 +214,7 @@ const QUALIA_HOOK_FILES = [
|
|
|
214
214
|
"env-empty-guard.js",
|
|
215
215
|
"supabase-destructive-guard.js",
|
|
216
216
|
"vercel-account-guard.js",
|
|
217
|
+
"secret-guard.js",
|
|
217
218
|
];
|
|
218
219
|
const QUALIA_LEGACY_HOOK_FILES = [
|
|
219
220
|
"block-env-edit.js", // removed in v3.2.0
|
|
@@ -712,6 +713,7 @@ function cmdMigrate() {
|
|
|
712
713
|
"vercel-account-guard.js",
|
|
713
714
|
"env-empty-guard.js",
|
|
714
715
|
"supabase-destructive-guard.js",
|
|
716
|
+
"secret-guard.js",
|
|
715
717
|
];
|
|
716
718
|
const requiredEditHooks = ["migration-guard.js"];
|
|
717
719
|
|
|
@@ -747,6 +749,7 @@ function cmdMigrate() {
|
|
|
747
749
|
if (hookFile === "vercel-account-guard.js") { hookDef.if = "Bash(vercel --prod*)|Bash(vercel deploy*)"; hookDef.timeout = 8; hookDef.statusMessage = "⬢ Verifying Vercel account..."; }
|
|
748
750
|
if (hookFile === "env-empty-guard.js") { hookDef.if = "Bash(vercel env*)"; hookDef.statusMessage = "⬢ Checking env value..."; }
|
|
749
751
|
if (hookFile === "supabase-destructive-guard.js") { hookDef.if = "Bash(supabase*)|Bash(npx supabase*)"; hookDef.statusMessage = "⬢ Checking Supabase safety..."; }
|
|
752
|
+
if (hookFile === "secret-guard.js") { hookDef.if = "Bash(git commit*)"; hookDef.timeout = 10; hookDef.statusMessage = "⬢ Scanning staged content for secrets..."; }
|
|
750
753
|
bashEntry.hooks.push(hookDef);
|
|
751
754
|
changes++;
|
|
752
755
|
console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Bash`);
|
|
@@ -1011,7 +1014,9 @@ async function cmdErpPing() {
|
|
|
1011
1014
|
project: "qualia-framework-erp-ping",
|
|
1012
1015
|
project_id: "ping",
|
|
1013
1016
|
team_id: "qualia-solutions",
|
|
1014
|
-
client_report_id:
|
|
1017
|
+
// client_report_id intentionally omitted: ERP only accepts a QS-REPORT-NN
|
|
1018
|
+
// form or an absent field. A synthetic "QS-PING-NN" is rejected (422). With
|
|
1019
|
+
// it absent the server assigns a throwaway UUID — exactly what a dry-run ping wants.
|
|
1015
1020
|
phase: 0,
|
|
1016
1021
|
phase_name: "ping",
|
|
1017
1022
|
status: "setup",
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dep-verify — Hallucinated / slopsquatted dependency scanner for Qualia projects.
|
|
4
|
+
*
|
|
5
|
+
* The harness gate that AI-generated code most often trips: importing a package
|
|
6
|
+
* that does not exist. The model invents a plausible name (`react-use-toast`),
|
|
7
|
+
* or typosquats a real one (`lodahs`), and the import only fails at install or
|
|
8
|
+
* runtime — long after the agent reported success. This scanner catches it at
|
|
9
|
+
* the verify/ship gate, deterministically and with zero network calls.
|
|
10
|
+
*
|
|
11
|
+
* The signal is precise: a bare import is flagged ONLY when its package is
|
|
12
|
+
* BOTH undeclared in package.json AND absent from node_modules. That is the
|
|
13
|
+
* exact signature of a hallucinated or slopsquatted dependency — and it keeps
|
|
14
|
+
* false positives near zero (phantom/hoisted deps that are actually installed
|
|
15
|
+
* are left alone).
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node bin/dep-verify.mjs # scan repo from cwd
|
|
19
|
+
* node bin/dep-verify.mjs src/app/page.tsx # scan one file
|
|
20
|
+
* node bin/dep-verify.mjs src/ # scan a directory
|
|
21
|
+
* node bin/dep-verify.mjs --json # machine-readable output
|
|
22
|
+
* node bin/dep-verify.mjs --severity=critical # parity flag (all findings are critical)
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 no undeclared/uninstalled imports
|
|
26
|
+
* 1 one or more hallucinated/slopsquatted imports found
|
|
27
|
+
* 2 invocation error
|
|
28
|
+
*
|
|
29
|
+
* Builder agents and the /qualia-verify + /qualia-ship gates call this BEFORE
|
|
30
|
+
* commit/deploy. A non-zero exit blocks. Pairs with slop-detect.mjs (design
|
|
31
|
+
* tells) — this is the correctness/security half of the same gate.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
35
|
+
import { join, extname, relative, resolve, dirname } from "node:path";
|
|
36
|
+
import { argv, exit, cwd } from "node:process";
|
|
37
|
+
import { builtinModules } from "node:module";
|
|
38
|
+
|
|
39
|
+
// ── Config ────────────────────────────────────────────────────────────
|
|
40
|
+
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
41
|
+
const SKIP_DIRS = new Set([
|
|
42
|
+
"node_modules", ".git", "dist", "build", ".next", "out",
|
|
43
|
+
"coverage", ".turbo", ".vercel", "vendor", ".cache",
|
|
44
|
+
]);
|
|
45
|
+
// Node builtins, with and without the `node:` prefix.
|
|
46
|
+
const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);
|
|
47
|
+
|
|
48
|
+
// ── Import extraction ─────────────────────────────────────────────────
|
|
49
|
+
// A context-free comment regex is unsafe here: a `//` inside a string or
|
|
50
|
+
// regex literal would erase the rest of the line (dropping a real import → a
|
|
51
|
+
// silent miss), and import-looking text *inside* a string would be matched as
|
|
52
|
+
// a live import (a false block). So we strip comments with a small scanner
|
|
53
|
+
// that tracks string, template, and regex-literal state, then match imports
|
|
54
|
+
// only at statement boundaries so syntax embedded in a string never counts.
|
|
55
|
+
|
|
56
|
+
// Heuristic for whether a `/` begins a regex literal: it does when the last
|
|
57
|
+
// significant code character is an operator/opener (not a value/identifier).
|
|
58
|
+
function isRegexStart(prev) {
|
|
59
|
+
return prev === "" || "(,=:[!&|?{};+-*%^~<>".includes(prev);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Remove // and /* */ comments while preserving strings, template literals,
|
|
63
|
+
// and regex literals (and preserving newlines so line numbers are stable).
|
|
64
|
+
function stripComments(src) {
|
|
65
|
+
let out = "";
|
|
66
|
+
let i = 0;
|
|
67
|
+
const n = src.length;
|
|
68
|
+
let state = "code"; // code | line | block | regex | sq | dq | tpl
|
|
69
|
+
let prevSig = ""; // last significant (non-whitespace) code char emitted
|
|
70
|
+
while (i < n) {
|
|
71
|
+
const c = src[i];
|
|
72
|
+
const c2 = src[i + 1];
|
|
73
|
+
if (state === "code") {
|
|
74
|
+
if (c === "/" && c2 === "/") { state = "line"; i += 2; continue; }
|
|
75
|
+
if (c === "/" && c2 === "*") { state = "block"; i += 2; continue; }
|
|
76
|
+
if (c === "/" && isRegexStart(prevSig)) { state = "regex"; out += c; i++; continue; }
|
|
77
|
+
if (c === "'") { state = "sq"; out += c; prevSig = c; i++; continue; }
|
|
78
|
+
if (c === '"') { state = "dq"; out += c; prevSig = c; i++; continue; }
|
|
79
|
+
if (c === "`") { state = "tpl"; out += c; prevSig = c; i++; continue; }
|
|
80
|
+
out += c;
|
|
81
|
+
if (!/\s/.test(c)) prevSig = c;
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (state === "line") {
|
|
86
|
+
if (c === "\n") { state = "code"; out += c; }
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (state === "block") {
|
|
91
|
+
if (c === "*" && c2 === "/") { state = "code"; i += 2; continue; }
|
|
92
|
+
if (c === "\n") out += c;
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// regex literal or string: copy verbatim, honoring escapes.
|
|
97
|
+
out += c;
|
|
98
|
+
if (c === "\\") {
|
|
99
|
+
if (i + 1 < n) { out += src[i + 1]; i += 2; continue; }
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (state === "regex" && c === "/") { state = "code"; prevSig = "/"; }
|
|
104
|
+
else if (state === "sq" && c === "'") { state = "code"; prevSig = c; }
|
|
105
|
+
else if (state === "dq" && c === '"') { state = "code"; prevSig = c; }
|
|
106
|
+
else if (state === "tpl" && c === "`") { state = "code"; prevSig = c; }
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Statement-anchored so import/export keywords inside a string literal don't
|
|
113
|
+
// match (they are preceded by a quote, not a statement boundary). `m` flag so
|
|
114
|
+
// `^` anchors each line; the negated class spans multi-line import bodies.
|
|
115
|
+
const ES_FROM_RE = /(?:^|[;{}])\s*(?:import|export)\b[^'";]*?from\s*['"]([^'"]+)['"]/gm;
|
|
116
|
+
const BARE_IMPORT_RE = /(?:^|[;{}])\s*import\s*['"]([^'"]+)['"]/gm;
|
|
117
|
+
// require()/import() can be inline; exclude member/identifier prefixes
|
|
118
|
+
// (myRequire(, foo.import() ) via lookbehind.
|
|
119
|
+
const CALL_RE = /(?<![\w$.])(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
120
|
+
|
|
121
|
+
function extractSpecifiers(source) {
|
|
122
|
+
const cleaned = stripComments(source);
|
|
123
|
+
const out = new Set();
|
|
124
|
+
for (const re of [ES_FROM_RE, BARE_IMPORT_RE, CALL_RE]) {
|
|
125
|
+
re.lastIndex = 0;
|
|
126
|
+
let m;
|
|
127
|
+
while ((m = re.exec(cleaned)) !== null) {
|
|
128
|
+
if (m[1]) out.add(m[1]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...out];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolve a raw specifier to its installable package name, or null if it is
|
|
135
|
+
// not an external package (relative, absolute, alias, or subpath import).
|
|
136
|
+
function packageName(spec) {
|
|
137
|
+
if (!spec) return null;
|
|
138
|
+
if (spec.startsWith(".") || spec.startsWith("/")) return null; // relative / absolute
|
|
139
|
+
if (spec.includes(":")) return null; // protocol specifiers: node:, virtual:, https:, data:, blob:
|
|
140
|
+
if (spec.startsWith("@/") || spec.startsWith("~/") || spec.startsWith("#")) return null; // common aliases / subpath imports
|
|
141
|
+
if (spec.startsWith("@")) {
|
|
142
|
+
const parts = spec.split("/");
|
|
143
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null; // @scope/name
|
|
144
|
+
}
|
|
145
|
+
return spec.split("/")[0]; // name (drop subpath)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── package.json + tsconfig discovery (walk up, cached) ───────────────
|
|
149
|
+
const declaredCache = new Map();
|
|
150
|
+
const aliasCache = new Map();
|
|
151
|
+
|
|
152
|
+
function readJSONSafe(p) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Union of declared deps from the nearest package.json walking up, plus the
|
|
161
|
+
// top-most (repo-root) package.json to tolerate monorepo hoisting.
|
|
162
|
+
function declaredDepsFor(fileDir) {
|
|
163
|
+
if (declaredCache.has(fileDir)) return declaredCache.get(fileDir);
|
|
164
|
+
const names = new Set();
|
|
165
|
+
let dir = fileDir;
|
|
166
|
+
let last = null;
|
|
167
|
+
while (true) {
|
|
168
|
+
const pj = join(dir, "package.json");
|
|
169
|
+
if (existsSync(pj)) {
|
|
170
|
+
const json = readJSONSafe(pj);
|
|
171
|
+
if (json) {
|
|
172
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
173
|
+
for (const k of Object.keys(json[field] || {})) names.add(k);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
last = dir;
|
|
177
|
+
}
|
|
178
|
+
const parent = dirname(dir);
|
|
179
|
+
if (parent === dir) break;
|
|
180
|
+
dir = parent;
|
|
181
|
+
}
|
|
182
|
+
declaredCache.set(fileDir, names);
|
|
183
|
+
return names;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// tsconfig/jsconfig path-alias prefixes (e.g. "@app/*" → "@app/").
|
|
187
|
+
function aliasPrefixesFor(fileDir) {
|
|
188
|
+
if (aliasCache.has(fileDir)) return aliasCache.get(fileDir);
|
|
189
|
+
const prefixes = [];
|
|
190
|
+
let dir = fileDir;
|
|
191
|
+
while (true) {
|
|
192
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
193
|
+
const cfg = readJSONSafe(join(dir, name));
|
|
194
|
+
const paths = cfg?.compilerOptions?.paths;
|
|
195
|
+
if (paths) {
|
|
196
|
+
for (const key of Object.keys(paths)) {
|
|
197
|
+
// A wildcard alias ("@app/*") suppresses its subtree; a non-wildcard
|
|
198
|
+
// alias ("@env") suppresses only the exact specifier. Without this
|
|
199
|
+
// distinction a bare "@utils" would wrongly swallow "@utils-extended/server".
|
|
200
|
+
if (key.endsWith("*")) {
|
|
201
|
+
const p = key.replace(/\*$/, "");
|
|
202
|
+
prefixes.push({ wild: true, value: p.endsWith("/") ? p : `${p}/` });
|
|
203
|
+
} else {
|
|
204
|
+
prefixes.push({ wild: false, value: key });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const parent = dirname(dir);
|
|
210
|
+
if (parent === dir) break;
|
|
211
|
+
dir = parent;
|
|
212
|
+
}
|
|
213
|
+
aliasCache.set(fileDir, prefixes);
|
|
214
|
+
return prefixes;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Is the package physically installed in any node_modules up the tree?
|
|
218
|
+
function isInstalled(fileDir, pkg) {
|
|
219
|
+
let dir = fileDir;
|
|
220
|
+
while (true) {
|
|
221
|
+
if (existsSync(join(dir, "node_modules", pkg))) return true;
|
|
222
|
+
const parent = dirname(dir);
|
|
223
|
+
if (parent === dir) break;
|
|
224
|
+
dir = parent;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── File walking ──────────────────────────────────────────────────────
|
|
230
|
+
function collectFiles(target) {
|
|
231
|
+
let st;
|
|
232
|
+
try {
|
|
233
|
+
st = statSync(target);
|
|
234
|
+
} catch {
|
|
235
|
+
return null; // signal: path does not exist
|
|
236
|
+
}
|
|
237
|
+
if (st.isFile()) return SOURCE_EXT.has(extname(target)) ? [target] : [];
|
|
238
|
+
const files = [];
|
|
239
|
+
const walk = (d) => {
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const e of entries) {
|
|
247
|
+
if (e.isDirectory()) {
|
|
248
|
+
if (!SKIP_DIRS.has(e.name)) walk(join(d, e.name));
|
|
249
|
+
} else if (e.isFile() && SOURCE_EXT.has(extname(e.name))) {
|
|
250
|
+
files.push(join(d, e.name));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
walk(target);
|
|
255
|
+
return files;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Scan ──────────────────────────────────────────────────────────────
|
|
259
|
+
function scanFile(file) {
|
|
260
|
+
const findings = [];
|
|
261
|
+
let source;
|
|
262
|
+
try {
|
|
263
|
+
source = readFileSync(file, "utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
const fileDir = dirname(resolve(file));
|
|
268
|
+
const declared = declaredDepsFor(fileDir);
|
|
269
|
+
const aliases = aliasPrefixesFor(fileDir);
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
for (const spec of extractSpecifiers(source)) {
|
|
272
|
+
if (aliases.some((a) => (a.wild ? spec.startsWith(a.value) : spec === a.value))) continue;
|
|
273
|
+
const pkg = packageName(spec);
|
|
274
|
+
if (!pkg) continue;
|
|
275
|
+
if (BUILTINS.has(pkg) || BUILTINS.has(spec)) continue;
|
|
276
|
+
if (declared.has(pkg)) continue;
|
|
277
|
+
if (isInstalled(fileDir, pkg)) continue;
|
|
278
|
+
if (seen.has(pkg)) continue; // one finding per package per file
|
|
279
|
+
seen.add(pkg);
|
|
280
|
+
findings.push({ file: relative(cwd(), file), package: pkg, specifier: spec });
|
|
281
|
+
}
|
|
282
|
+
return findings;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── CLI ───────────────────────────────────────────────────────────────
|
|
286
|
+
function main() {
|
|
287
|
+
const args = argv.slice(2);
|
|
288
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
289
|
+
console.log("dep-verify — flags imports that are neither declared in package.json nor installed.");
|
|
290
|
+
console.log("Usage: node bin/dep-verify.mjs [paths...] [--json] [--severity=critical]");
|
|
291
|
+
exit(0);
|
|
292
|
+
}
|
|
293
|
+
const json = args.includes("--json");
|
|
294
|
+
const paths = args.filter((a) => !a.startsWith("--") && !a.startsWith("-"));
|
|
295
|
+
const targets = paths.length ? paths : [cwd()];
|
|
296
|
+
|
|
297
|
+
const allFiles = [];
|
|
298
|
+
for (const t of targets) {
|
|
299
|
+
const files = collectFiles(t);
|
|
300
|
+
if (files === null) {
|
|
301
|
+
if (json) console.log(JSON.stringify({ ok: false, error: "ENOENT", path: t }, null, 2));
|
|
302
|
+
else console.error(`dep-verify: path not found: ${t}`);
|
|
303
|
+
exit(2);
|
|
304
|
+
}
|
|
305
|
+
allFiles.push(...files);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const findings = [];
|
|
309
|
+
for (const f of allFiles) findings.push(...scanFile(f));
|
|
310
|
+
|
|
311
|
+
if (json) {
|
|
312
|
+
console.log(JSON.stringify({ ok: findings.length === 0, scanned: allFiles.length, findings }, null, 2));
|
|
313
|
+
} else if (findings.length === 0) {
|
|
314
|
+
console.log(`dep-verify: ${allFiles.length} file(s) scanned — no hallucinated dependencies.`);
|
|
315
|
+
} else {
|
|
316
|
+
console.error(`dep-verify: ${findings.length} hallucinated/slopsquatted import(s) — undeclared AND not installed:\n`);
|
|
317
|
+
for (const f of findings) {
|
|
318
|
+
console.error(` ✗ ${f.file}`);
|
|
319
|
+
console.error(` imports "${f.specifier}" → package "${f.package}" is not in package.json and not in node_modules`);
|
|
320
|
+
}
|
|
321
|
+
console.error(`\n Fix: confirm the package name is real, then add it to package.json (npm/pnpm/yarn add),`);
|
|
322
|
+
console.error(` or correct the import. AI commonly invents or typosquats package names.`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
exit(findings.length === 0 ? 0 : 1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
main();
|
package/bin/install.js
CHANGED
|
@@ -1283,6 +1283,8 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1283
1283
|
"fawzi-approval-guard.js", "task-write-guard.js",
|
|
1284
1284
|
// v5.0 — insights-driven destructive-op + wrong-account guards
|
|
1285
1285
|
"vercel-account-guard.js", "env-empty-guard.js", "supabase-destructive-guard.js",
|
|
1286
|
+
// commit-time secret gate (constitution: never commit service_role / .env)
|
|
1287
|
+
"secret-guard.js",
|
|
1286
1288
|
// performance-audit telemetry capture (UserPromptSubmit)
|
|
1287
1289
|
"usage-capture.js",
|
|
1288
1290
|
]);
|
|
@@ -1316,6 +1318,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1316
1318
|
{ type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "⬢ Verifying Vercel account..." },
|
|
1317
1319
|
{ type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "⬢ Checking env value..." },
|
|
1318
1320
|
{ type: "command", if: "Bash(supabase*)|Bash(npx supabase*)", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5, statusMessage: "⬢ Checking Supabase safety..." },
|
|
1321
|
+
{ type: "command", if: "Bash(git commit*)", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
|
|
1319
1322
|
],
|
|
1320
1323
|
},
|
|
1321
1324
|
{
|
|
@@ -1569,6 +1572,12 @@ function printSummary({ member, target, claudeInstalled }) {
|
|
|
1569
1572
|
console.log(
|
|
1570
1573
|
` ${DIM} performance audit. Opt out:${RESET} ${TEAL}erp.capturePrompts=false${RESET} ${DIM}in ~/.claude/.qualia-config.json${RESET}`
|
|
1571
1574
|
);
|
|
1575
|
+
// New employees get the milestone mental model up front — OWNERs already
|
|
1576
|
+
// know how the flow works, so we skip the card for them to avoid noise.
|
|
1577
|
+
if (member.role !== "OWNER") {
|
|
1578
|
+
try { ui.onboard(member.name); } catch {}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1572
1581
|
console.log("");
|
|
1573
1582
|
console.log(` ${DIM2}${RULE}${RESET}`);
|
|
1574
1583
|
console.log(` ${TEAL}${BOLD}Welcome to the future with Qualia.${RESET}`);
|
|
@@ -1861,6 +1870,7 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1861
1870
|
{ type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
|
|
1862
1871
|
{ type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
|
|
1863
1872
|
{ type: "command", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5 },
|
|
1873
|
+
{ type: "command", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
|
|
1864
1874
|
],
|
|
1865
1875
|
},
|
|
1866
1876
|
{
|
|
@@ -1868,6 +1878,11 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1868
1878
|
hooks: [
|
|
1869
1879
|
{ type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
|
|
1870
1880
|
{ type: "command", command: nodeCmd("migration-guard.js"), timeout: 10 },
|
|
1881
|
+
// Runtime parity with Claude (install.js Edit|Write group): the
|
|
1882
|
+
// off-contract-write gate must run on Codex too, else a Codex
|
|
1883
|
+
// builder can write fabricated/out-of-scope files unblocked.
|
|
1884
|
+
// Enforced by tests/runtime-parity.test.sh.
|
|
1885
|
+
{ type: "command", command: nodeCmd("task-write-guard.js"), timeout: 5, statusMessage: "⬢ Checking plan-contract file scope..." },
|
|
1871
1886
|
],
|
|
1872
1887
|
},
|
|
1873
1888
|
],
|