promptwheel 0.4.1 → 0.4.3

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml/badge.svg)](https://github.com/promptwheel-ai/promptwheel/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Node ≥18](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![deps: zero](https://img.shields.io/badge/deps-zero-blue)
4
4
 
5
- **Catch your agent cheating** — the deterministic auditor that flags when your AI coding agent gamed its own success metric.
5
+ **Add a referee to your coding loop.** A green test should mean your agent *fixed the code* — not that it edited the test to match the bug. PromptWheel is the referee that tells the difference: deterministic, no LLM, and un-gameable, because a player can't call its own fouls.
6
6
 
7
7
  PromptWheel re-proves every "win" using the agent's **source edits alone**. If the gate only went green because the agent edited the test, mocked the grader, suppressed the error (`@ts-ignore` / `eslint-disable`), or deleted the feature, the win evaporates when those edits are reverted → **`VERDICT GAMED`, exit 2**. No LLM in the loop — a diff partition plus a re-run, so every flag is reproducible in seconds with a human-readable reason.
8
8
 
@@ -242,7 +242,7 @@ The engine is one importable file; pure helpers are exported for unit tests, the
242
242
  - [x] `promptwheel init` + presets — zero-config onboarding
243
243
  - [x] `insights` — reward-stream aggregation (Phase-5 seed)
244
244
  - [x] `--detect-gaming` — reward-hack detection: re-prove the win from source edits alone + `antihack` preset
245
- - [x] npm publish — `promptwheel@0.4.1` (the lead magnet)
245
+ - [x] npm publish — `promptwheel@0.4.2` (the lead magnet)
246
246
  - [x] outcome-curated learning + UCB work-discovery — `playbook` + `suggest` + the compounding A/B harness (**experimental**; compounding *claims* stay gated on real-data proof — see [docs/LEARNING.md](docs/LEARNING.md))
247
247
 
248
- > Status: **published** (npm `promptwheel`, v0.4.1) — all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
248
+ > Status: **published** (npm `promptwheel`, v0.4.2) — all core phases built. Lineage: CommandLayer → BlockSpool → PromptWheel (orchestrator, archived) → **PromptWheel (outcome gate)**.
@@ -11,7 +11,7 @@
11
11
  // promptwheel run [--base R] [--head R] [--repeat N] [--json]
12
12
 
13
13
  import { execSync, execFileSync } from 'node:child_process';
14
- import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync, readdirSync, statSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, symlinkSync, rmSync, mkdirSync, appendFileSync, realpathSync, readdirSync, statSync, copyFileSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { join, dirname, isAbsolute, relative } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
@@ -87,13 +87,19 @@ function runMetric(cwd, m, env) {
87
87
  try {
88
88
  stdout = execSync(m.cmd, { cwd, env: env || process.env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: (m.timeoutSec ?? 300) * 1000 });
89
89
  } catch (e) { code = e.status ?? 1; stdout = `${e.stdout || ''}${e.stderr || ''}`; }
90
- return extract(stdout, code, m.extract);
90
+ return extract(stdout, code, m.extract, m.allowNonzero);
91
91
  }
92
92
 
93
93
  // extract modes: 'number' (last number, default) | 'lines' | 'exit' | {regex}
94
- function extract(stdout, code, mode) {
94
+ // A FAILED command is unmeasurable (null -> the metric goes inert, guards
95
+ // fail loudly) — error text often contains numbers ("Node.js v22") and
96
+ // silently measuring those produced identical junk on both refs = a false
97
+ // green. 'exit' mode consumes nonzero by design; anything else needs the
98
+ // per-metric opt-in `allowNonzero: true`.
99
+ function extract(stdout, code, mode, allowNonzero) {
95
100
  mode = mode || 'number';
96
101
  if (mode === 'exit') return code === 0 ? 1 : 0;
102
+ if (code !== 0 && !allowNonzero) return null;
97
103
  if (mode === 'lines') return stdout.split('\n').filter((l) => l.trim()).length;
98
104
  if (mode && typeof mode === 'object' && mode.regex) {
99
105
  const mm = new RegExp(mode.regex).exec(stdout);
@@ -647,6 +653,8 @@ const PRESETS = {
647
653
  metrics: [{ name: 'lint_errors', cmd: LINT_CMD, extract: 'number', direction: 'down', guard: false }] },
648
654
  'bundle-size': { desc: 'track: build output size in kB',
649
655
  metrics: [{ name: 'bundle_kb', cmd: 'du -sk dist 2>/dev/null | cut -f1 || echo 0', extract: 'number', direction: 'down', guard: false }] },
656
+ 'security': { desc: 'gate: security-invariant violations must not climb (CORS, secrets, cookies, payment; see packs/security)',
657
+ metrics: [{ name: 'security_findings', cmd: 'node packs/security/scan.mjs .', extract: 'number', direction: 'down', guard: true }] },
650
658
  'llm-eval': { desc: 'gate: AI-feature eval pass-rate + est $/run (see examples/reliability-sprint)',
651
659
  metrics: [
652
660
  { name: 'eval_pass_rate', cmd: 'node eval.mjs', extract: 'number', direction: 'up', guard: true },
@@ -717,6 +725,19 @@ function init(argv) {
717
725
  if (!p) { console.error(`unknown preset "${presetName}" — try: promptwheel init --list`); process.exit(2); }
718
726
  metrics = p.metrics || [{ name: 'tests_pass', cmd: detectTestCmd(repo), extract: 'exit', direction: 'pass', guard: true }];
719
727
  metrics = metrics.map((m) => m.cmd === '__TESTCMD__' ? { ...m, cmd: detectTestCmd(repo) } : m); // fill the preset's target
728
+ // pack-backed presets: the referenced scripts live in THIS package, not
729
+ // the user's repo — copy them in so the generated config is
730
+ // self-contained and committable (a bare reference silently 404'd)
731
+ metrics = metrics.map((m) => {
732
+ const pk = /(^|\s)(packs\/[\w-]+)\//.exec(m.cmd);
733
+ if (!pk) return m;
734
+ const src = fileURLToPath(new URL(`../${pk[2]}/`, import.meta.url));
735
+ const dst = join(repo, '.promptwheel', pk[2]);
736
+ mkdirSync(dst, { recursive: true });
737
+ for (const f of readdirSync(src)) copyFileSync(join(src, f), join(dst, f));
738
+ console.log(` ✓ installed ${pk[2]} → .promptwheel/${pk[2]} (commit it with the config)`);
739
+ return { ...m, cmd: m.cmd.replaceAll(pk[2] + '/', `.promptwheel/${pk[2]}/`) };
740
+ });
720
741
  note = presetName;
721
742
  } else {
722
743
  // default = the headline posture: guarded tests + the antihack tripwires, so the FIRST
package/package.json CHANGED
@@ -1,22 +1,46 @@
1
1
  {
2
2
  "name": "promptwheel",
3
- "version": "0.4.1",
4
- "description": "Catch your agent cheating — a deterministic, zero-LLM CLI that flags when an AI coding agent gamed its own metric (re-proves the win from the agent's source edits alone). Built on an outcome gate; runs in CI or a Claude Code hook.",
3
+ "version": "0.4.3",
4
+ "description": "Add a referee to your coding loop \u2014 a deterministic, zero-LLM CLI that checks every change earned its win: it re-proves the result from the agent's source edits alone, so a green that came from editing the test reads GAMED. Runs in CI or a Claude Code hook.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "promptwheel": "bin/promptwheel.mjs",
8
8
  "pw": "bin/promptwheel.mjs"
9
9
  },
10
- "files": ["bin"],
11
- "engines": { "node": ">=18" },
10
+ "files": [
11
+ "bin",
12
+ "packs"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
12
17
  "scripts": {
13
18
  "gate": "node bin/promptwheel.mjs run",
14
19
  "test": "node --test"
15
20
  },
16
- "repository": { "type": "git", "url": "git+https://github.com/promptwheel-ai/promptwheel.git" },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/promptwheel-ai/promptwheel.git"
24
+ },
17
25
  "homepage": "https://github.com/promptwheel-ai/promptwheel#readme",
18
- "bugs": { "url": "https://github.com/promptwheel-ai/promptwheel/issues" },
26
+ "bugs": {
27
+ "url": "https://github.com/promptwheel-ai/promptwheel/issues"
28
+ },
19
29
  "author": "Matthew Owens",
20
30
  "license": "MIT",
21
- "keywords": ["agent", "ci", "regression", "metrics", "verification", "ai-coding", "outcome", "evals", "loop", "promptwheel", "reward-hacking", "detect-gaming", "reward-hack-detection"]
22
- }
31
+ "keywords": [
32
+ "agent",
33
+ "ci",
34
+ "regression",
35
+ "metrics",
36
+ "verification",
37
+ "ai-coding",
38
+ "outcome",
39
+ "evals",
40
+ "loop",
41
+ "promptwheel",
42
+ "reward-hacking",
43
+ "detect-gaming",
44
+ "reward-hack-detection"
45
+ ]
46
+ }
@@ -0,0 +1,52 @@
1
+ # PromptWheel security guard-pack
2
+
3
+ Gate your AI coding loop on **security invariants**, not just tests. When an agent
4
+ writes backend code, "tests pass" says nothing about whether it shipped a CORS
5
+ wildcard, a client-controlled payment amount, or a hardcoded secret. This pack
6
+ adds a `security_findings` metric that must not climb — and because it's a
7
+ PromptWheel metric, the win has to survive `--detect-gaming`: an agent can't
8
+ lower the count by editing the pattern file or a config, only by fixing the code.
9
+
10
+ ## Use it
11
+
12
+ In your `promptwheel.config.json`:
13
+
14
+ ```json
15
+ { "extends": ["packs/security/pack.json"] }
16
+ ```
17
+
18
+ Then gate as usual:
19
+
20
+ ```bash
21
+ promptwheel run --working # fails if security_findings increased
22
+ ```
23
+
24
+ A finding-count that drops because you fixed the code is a `PASS`; a "fix" that
25
+ only survives with test/config/pattern edits reverted is `GAMED`.
26
+
27
+ ## What it checks (v0.1 — 8 high-precision invariants)
28
+
29
+ Decanted from [securitychecks](https://securitychecks.ai)' "recommended" tier —
30
+ the regex-portable subset, chosen for near-zero false positives:
31
+
32
+ | Invariant | Severity |
33
+ | --- | --- |
34
+ | CORS wildcard origin | P0 |
35
+ | CORS origin reflection | P0 |
36
+ | Insecure session cookie (`secure:false`) | P0 |
37
+ | Payment amount from client input | P0 |
38
+ | Hardcoded secret in a sensitive variable | P0 |
39
+ | NPM token committed in `.npmrc` | P0 |
40
+ | Template autoescape disabled (XSS) | P0 |
41
+ | S3 presigned upload without content validation | P2 |
42
+
43
+ Each pattern ships with its own false-positive guards (the `exclude` regexes).
44
+
45
+ ## Scope, honestly
46
+
47
+ This is the **regex-portable** slice. The other ~75 recommended invariants
48
+ (auth-enforcement, authz-revocation, tenant-isolation, dataflow-tainted sinks)
49
+ need the AST + dataflow engine and are the follow-up — see the decant note in
50
+ `explorations/`. The point of v0.1 is the architecture: security invariants are
51
+ now a first-class, un-gameable PromptWheel metric. Zero dependencies; the scanner
52
+ is one file (`scan.mjs`).
@@ -0,0 +1,12 @@
1
+ {
2
+ "$comment": "PromptWheel security guard-pack. Decanted from securitychecks (recommended tier). Use via { \"extends\": [\"packs/security/pack.json\"] } in your promptwheel.config.json. Gates on the count of security-invariant violations (direction: down = lower is better; guard: true = fail if it climbs). Because it's a promptwheel metric, the win must survive --detect-gaming: an agent can't lower the count by editing the pattern file or a config, only by fixing the code.",
3
+ "metrics": [
4
+ {
5
+ "name": "security_findings",
6
+ "cmd": "node packs/security/scan.mjs .",
7
+ "extract": "number",
8
+ "direction": "down",
9
+ "guard": true
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,209 @@
1
+ {
2
+ "pack": "security",
3
+ "version": "0.1.0",
4
+ "source": "securitychecks recommended tier (regex-portable subset)",
5
+ "patterns": [
6
+ {
7
+ "id": "DATAFLOW.UNTRUSTED.XSS",
8
+ "name": "Template Engine Autoescape Disabled",
9
+ "severity": "P0",
10
+ "match": "autoescape\\s*:\\s*false",
11
+ "exclude": [],
12
+ "message": "Template engine autoescape disabled \u2014 enables stored XSS attacks",
13
+ "fix": "Enable autoescape or sanitize all user input before rendering",
14
+ "files": [
15
+ "**/*.ts",
16
+ "**/*.js",
17
+ "!**/__tests__/**",
18
+ "!**/*.test.*",
19
+ "!**/*.spec.*",
20
+ "!**/test/**",
21
+ "!**/tests/**",
22
+ "!**/__mocks__/**",
23
+ "!**/fixtures/**",
24
+ "!**/www/**",
25
+ "!**/docs/**",
26
+ "!**/documentation/**"
27
+ ]
28
+ },
29
+ {
30
+ "id": "SESSION.INSECURE_COOKIE",
31
+ "name": "Insecure Session Cookie",
32
+ "severity": "P0",
33
+ "match": "secure\\s*:\\s*false",
34
+ "exclude": [],
35
+ "message": "Session cookies must have secure and httpOnly flags",
36
+ "fix": "Verify this is not a false positive",
37
+ "files": [
38
+ "**/*.ts",
39
+ "**/*.js",
40
+ "**/*.tsx",
41
+ "**/*.jsx",
42
+ "!**/__tests__/**",
43
+ "!**/*.test.*",
44
+ "!**/*.spec.*",
45
+ "!**/test/**",
46
+ "!**/tests/**",
47
+ "!**/__mocks__/**",
48
+ "!**/fixtures/**",
49
+ "!**/e2e/**",
50
+ "!**/playwright/**",
51
+ "!**/cypress/**",
52
+ "!**/test-utils/**",
53
+ "!**/*.e2e.*",
54
+ "!**/e2e-*/**"
55
+ ]
56
+ },
57
+ {
58
+ "id": "AUTHZ.CORS_WILDCARD",
59
+ "name": "CORS Wildcard Origin",
60
+ "severity": "P0",
61
+ "match": "Access-Control-Allow-Origin.*\\*|origin\\s*:\\s*\\*|origin:\\s*true",
62
+ "exclude": [
63
+ "!credentials"
64
+ ],
65
+ "message": "Wildcard origin with credentials exposes user data",
66
+ "fix": "Verify this is not a false positive",
67
+ "files": [
68
+ "**/*.ts",
69
+ "**/*.js",
70
+ "**/*.tsx",
71
+ "**/*.jsx",
72
+ "!**/__tests__/**",
73
+ "!**/*.test.*",
74
+ "!**/*.spec.*",
75
+ "!**/test/**",
76
+ "!**/tests/**",
77
+ "!**/__mocks__/**",
78
+ "!**/fixtures/**",
79
+ "!**/*.tsx",
80
+ "!**/*.jsx",
81
+ "!**/components/**"
82
+ ]
83
+ },
84
+ {
85
+ "id": "AUTHZ.CORS_REFLECT",
86
+ "name": "CORS Origin Reflection",
87
+ "severity": "P0",
88
+ "match": "(?:Access-Control-Allow-Origin|res\\.(?:set|setHeader|header)\\(['\"]Access-Control).*(?:req\\.headers\\.origin|headers\\.get\\(['\"]origin['\"]\\))",
89
+ "exclude": [],
90
+ "message": "Reflecting origin is equivalent to wildcard \u2014 validate against allowlist",
91
+ "fix": "Verify this is not a false positive",
92
+ "files": [
93
+ "**/*.ts",
94
+ "**/*.js",
95
+ "**/*.tsx",
96
+ "**/*.jsx",
97
+ "!**/__tests__/**",
98
+ "!**/*.test.*",
99
+ "!**/*.spec.*",
100
+ "!**/test/**",
101
+ "!**/tests/**",
102
+ "!**/__mocks__/**",
103
+ "!**/fixtures/**"
104
+ ]
105
+ },
106
+ {
107
+ "id": "PAY.CLIENT_AMOUNT",
108
+ "name": "Payment Amount From Client",
109
+ "severity": "P0",
110
+ "match": "amount.*req\\.body|price.*req\\.body|total.*body\\.amount",
111
+ "exclude": [
112
+ "server|database|catalog"
113
+ ],
114
+ "message": "Calculate payment amounts server-side, not from client input",
115
+ "fix": "Verify this is not a false positive",
116
+ "files": [
117
+ "**/*.ts",
118
+ "**/*.js",
119
+ "**/*.tsx",
120
+ "**/*.jsx",
121
+ "!**/__tests__/**",
122
+ "!**/*.test.*",
123
+ "!**/*.spec.*",
124
+ "!**/test/**",
125
+ "!**/tests/**",
126
+ "!**/__mocks__/**",
127
+ "!**/fixtures/**"
128
+ ]
129
+ },
130
+ {
131
+ "id": "CONFIG.NPMRC_TOKEN",
132
+ "name": "NPM Token in .npmrc",
133
+ "severity": "P0",
134
+ "match": "_authToken=|//registry.*:_auth",
135
+ "exclude": [
136
+ "\\$\\{NPM_TOKEN\\}|process\\.env"
137
+ ],
138
+ "message": "NPM tokens should come from environment variables",
139
+ "fix": "Verify this is not a false positive",
140
+ "files": [
141
+ "**/*",
142
+ "!**/__tests__/**",
143
+ "!**/*.test.*",
144
+ "!**/*.spec.*",
145
+ "!**/test/**",
146
+ "!**/tests/**",
147
+ "!**/__mocks__/**",
148
+ "!**/fixtures/**"
149
+ ]
150
+ },
151
+ {
152
+ "id": "VALID.UPLOAD_NOCHECK",
153
+ "name": "S3 Presigned Upload Without Content Validation",
154
+ "severity": "P2",
155
+ "match": "(?:createPresignedPost|getSignedUrl|PutObjectCommand)\\s*\\(",
156
+ "exclude": [
157
+ "ContentType|content-type|Content-Length|content-length|Conditions|conditions",
158
+ "allowedTypes|fileFilter|mimetype|maxSize|maxFileSize",
159
+ "@example|Example:|```|description:|guidance",
160
+ "GetObjectCommand|getObject|HeadObjectCommand|DeleteObjectCommand|download|Download|read|Read",
161
+ "generateSignedUrl.*get|getSignedUrl.*getObject"
162
+ ],
163
+ "message": "S3 presigned upload URL has no content type or size conditions \u2014 any file type/size can be uploaded",
164
+ "fix": "Add Conditions to presigned POST or ContentType/ContentLength constraints",
165
+ "files": [
166
+ "**/*.ts",
167
+ "**/*.js",
168
+ "!**/*.tsx",
169
+ "!**/*.jsx",
170
+ "!**/components/**",
171
+ "!**/frontend/**",
172
+ "!**/public/**",
173
+ "!**/__tests__/**",
174
+ "!**/*.test.*",
175
+ "!**/*.spec.*",
176
+ "!**/test/**",
177
+ "!**/tests/**",
178
+ "!**/__mocks__/**",
179
+ "!**/fixtures/**"
180
+ ]
181
+ },
182
+ {
183
+ "id": "SECRETS.HARDCODED",
184
+ "name": "Hardcoded Secret in Sensitive Variable",
185
+ "severity": "P0",
186
+ "match": "(?:const|let|var)\\s+(?:[A-Z_]*(?:SECRET|JWT_SECRET|API_KEY|AUTH_TOKEN|PRIVATE_KEY|SIGNING_KEY|ENCRYPTION_KEY)[A-Z_]*)\\s*=\\s*['\"][^'\"]{8,}['\"]",
187
+ "exclude": [
188
+ "process\\.env|import\\.meta\\.env|getenv|\\$\\{|placeholder|changeme|YOUR_|TODO|FIXME|example|EXAMPLE"
189
+ ],
190
+ "message": "Secret value hardcoded in source code \u2014 use environment variables",
191
+ "fix": "Verify this is not a placeholder or example value",
192
+ "files": [
193
+ "**/*.ts",
194
+ "**/*.js",
195
+ "!**/__tests__/**",
196
+ "!**/*.test.*",
197
+ "!**/*.spec.*",
198
+ "!**/test/**",
199
+ "!**/tests/**",
200
+ "!**/__mocks__/**",
201
+ "!**/fixtures/**",
202
+ "!**/sample/**",
203
+ "!**/samples/**",
204
+ "!**/example/**",
205
+ "!**/examples/**"
206
+ ]
207
+ }
208
+ ]
209
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // PromptWheel security guard-pack scanner — zero-dep.
3
+ // Decanted from securitychecks' "recommended" tier (the regex-portable subset).
4
+ // Emits the count of security-invariant violations to stdout (the metric value).
5
+ // The full AST+dataflow engine (the other 75 recommended patterns) is the follow-up.
6
+ //
7
+ // Usage: node scan.mjs [path] → prints the finding count (for `promptwheel` metrics)
8
+ // node scan.mjs [path] --list → prints each finding (file:line — id — message)
9
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
10
+ import { join, extname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const ROOT = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : '.';
14
+ const LIST = process.argv.includes('--list');
15
+ const PACK = JSON.parse(readFileSync(join(fileURLToPath(new URL('.', import.meta.url)), 'patterns.json'), 'utf8'));
16
+
17
+ const SKIP = /(^|\/)(node_modules|\.git|dist|build|\.next|coverage|vendor)(\/|$)/;
18
+ const CODE = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
19
+ const CONFIG_FILES = /(^|\/)\.npmrc$/;
20
+
21
+ function* walk(dir) {
22
+ let entries;
23
+ try { entries = readdirSync(dir); } catch { return; }
24
+ for (const e of entries) {
25
+ const p = join(dir, e);
26
+ if (SKIP.test(p)) continue;
27
+ let st;
28
+ try { st = statSync(p); } catch { continue; }
29
+ if (st.isDirectory()) yield* walk(p);
30
+ else if (CODE.has(extname(p)) || CONFIG_FILES.test(p)) yield p;
31
+ }
32
+ }
33
+
34
+ const findings = [];
35
+ for (const file of walk(ROOT)) {
36
+ let text;
37
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
38
+ const lines = text.split('\n');
39
+ for (const pat of PACK.patterns) {
40
+ let re;
41
+ try { re = new RegExp(pat.match, 'i'); } catch { continue; }
42
+ const excludes = (pat.exclude || []).map((x) => { try { return new RegExp(x, 'i'); } catch { return null; } }).filter(Boolean);
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i];
45
+ if (!re.test(line)) continue;
46
+ if (excludes.some((ex) => ex.test(line))) continue; // pattern's own false-positive guards
47
+ findings.push({ file, line: i + 1, id: pat.id, sev: pat.severity, msg: pat.message });
48
+ }
49
+ }
50
+ }
51
+
52
+ if (LIST) {
53
+ for (const f of findings) console.error(` [${f.sev}] ${f.file}:${f.line} — ${f.id} — ${f.msg}`);
54
+ console.error(`\n${findings.length} security-invariant violation(s)`);
55
+ }
56
+ // the metric value: lower is better, 0 is clean
57
+ process.stdout.write(String(findings.length) + '\n');