loki-mode 8.1.0 → 8.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/README.md +32 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +45 -4
- package/autonomy/council-v2.sh +63 -1
- package/autonomy/grill.sh +32 -0
- package/autonomy/lib/done-recognition.sh +16 -4
- package/autonomy/lib/fast_verify.py +346 -0
- package/autonomy/lib/no_mock_scan.py +21 -1
- package/autonomy/lib/prd-enrich.sh +7 -1
- package/autonomy/lib/proof-generator.py +146 -6
- package/autonomy/lib/proof-template.html +60 -12
- package/autonomy/lib/proof-verify.py +52 -0
- package/autonomy/loki +209 -2
- package/autonomy/verify.sh +12 -6
- package/dashboard/__init__.py +1 -1
- package/events/emit.sh +66 -6
- package/loki-ts/dist/loki.js +319 -316
- package/mcp/__init__.py +30 -12
- package/mcp/server.py +150 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +72 -0
- package/providers/codex.sh +13 -0
- package/providers/loader.sh +10 -4
- package/providers/model_catalog.json +50 -0
- package/providers/models.sh +26 -3
- package/providers/opencode.sh +145 -0
- package/references/design-archetypes.md +85 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ _The free, source-available autonomous coding agent by [Autonomi](https://www.au
|
|
|
15
15
|
|
|
16
16
|
[Website](https://www.autonomi.dev/) | [Documentation](wiki/Home.md) | [Installation](docs/INSTALLATION.md) | [Changelog](CHANGELOG.md) | [Purple Lab -- deprecated v7.44.0](#purple-lab)
|
|
17
17
|
|
|
18
|
-
**Current release: v8.
|
|
18
|
+
**Current release: v8.2.0**
|
|
19
19
|
|
|
20
20
|
</div>
|
|
21
21
|
|
|
@@ -25,6 +25,37 @@ _The free, source-available autonomous coding agent by [Autonomi](https://www.au
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
+
## Already have a codebase? Start read-only.
|
|
29
|
+
|
|
30
|
+
Most agents are built to create new apps. The harder, more valuable problem is
|
|
31
|
+
the ten-year-old repo that pays the bills. Loki works on both, and on an
|
|
32
|
+
existing codebase it starts by **changing nothing**:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
loki modernize heal ./your-repo --assess # read-only. no writes, no commits.
|
|
36
|
+
loki modernize heal ./your-repo --assess --json # same, machine-readable
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You get a modernization readiness report: language mix, a 4-level maturity
|
|
40
|
+
rating, technical-debt signals (test coverage, TODO density, oversized files,
|
|
41
|
+
dependency staleness), and a **ranked list of where to start** -- ordered by
|
|
42
|
+
blast radius, so the first change is the one least likely to break something.
|
|
43
|
+
|
|
44
|
+
Then, if you want it to act:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
loki modernize heal ./your-repo --strict # block ALL behavioral change without approval
|
|
48
|
+
loki modernize heal ./your-repo --phase archaeology # extract knowledge only
|
|
49
|
+
loki modernize heal ./your-repo --compliance healthcare # or fintech | government
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The healing pipeline runs in phases -- archaeology, stabilize, isolate,
|
|
53
|
+
modernize, validate -- and the validate phase checks **behavioral equivalence
|
|
54
|
+
against the pre-change baseline**, not just that the tests are green. Friction
|
|
55
|
+
points (the weird code that exists for a reason nobody remembers) are cataloged
|
|
56
|
+
before anything touches them, because in a legacy system the strange code is
|
|
57
|
+
usually load-bearing.
|
|
58
|
+
|
|
28
59
|
## The Evidence Receipt: don't trust the agent, check it
|
|
29
60
|
|
|
30
61
|
Every coding agent tells you it finished. Loki hands you something you can
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v8.
|
|
6
|
+
# Loki Mode v8.2.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
469
469
|
|
|
470
470
|
---
|
|
471
471
|
|
|
472
|
-
**v8.
|
|
472
|
+
**v8.2.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
8.
|
|
1
|
+
8.2.0
|
|
@@ -2267,11 +2267,17 @@ PYEOF
|
|
|
2267
2267
|
if [ -z "$_nm_cc_dir" ] || [ ! -f "$_nm_scanner" ]; then
|
|
2268
2268
|
_nm_status="INCONCLUSIVE:scanner_unavailable"
|
|
2269
2269
|
else
|
|
2270
|
+
# STDIN, not an env var: a single env string is capped at
|
|
2271
|
+
# MAX_ARG_STRLEN (131071 bytes) on Linux, so a large changed-file
|
|
2272
|
+
# union makes execve fail with E2BIG and this gate silently
|
|
2273
|
+
# degrades to inconclusive (pass-through). macOS has no per-string
|
|
2274
|
+
# cap, so that failure mode was Linux-only.
|
|
2270
2275
|
_nm_status=$(
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2276
|
+
printf '%s\n' "$_nm_files" | {
|
|
2277
|
+
_NM_TREE="." \
|
|
2278
|
+
python3 -I "$_nm_scanner" 2>/dev/null \
|
|
2279
|
+
|| echo "INCONCLUSIVE:detector_error"
|
|
2280
|
+
}
|
|
2275
2281
|
)
|
|
2276
2282
|
fi
|
|
2277
2283
|
case "$_nm_status" in
|
|
@@ -3135,6 +3141,25 @@ ISSUES: CRITICAL:description (optional, one per line per issue)"
|
|
|
3135
3141
|
_provider_rc=$?
|
|
3136
3142
|
fi
|
|
3137
3143
|
;;
|
|
3144
|
+
*)
|
|
3145
|
+
# v8.2.0 TIMEOUT SEAM. Any provider exposing provider_invoke_argv can
|
|
3146
|
+
# cast a real council vote instead of falling straight to the
|
|
3147
|
+
# heuristic. argv is a REAL command, so `timeout` bounds it exactly as
|
|
3148
|
+
# it bounds the named arms above (providers/claude.sh:321).
|
|
3149
|
+
#
|
|
3150
|
+
# SEMANTICS PRESERVED: _provider_rc is captured the same way, so the
|
|
3151
|
+
# bash-F4 safe default below still forces a conservative REJECT on a
|
|
3152
|
+
# timeout kill (124/137/143). An empty verdict falls to
|
|
3153
|
+
# council_heuristic_review, identical to a missing CLI today.
|
|
3154
|
+
if type provider_invoke_argv >/dev/null 2>&1; then
|
|
3155
|
+
provider_invoke_argv fast "$prompt"
|
|
3156
|
+
# caveman HARD-SUPPRESS: this vote is parsed for "VOTE:".
|
|
3157
|
+
verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" \
|
|
3158
|
+
env CAVEMAN_DEFAULT_MODE=off \
|
|
3159
|
+
"${_LOKI_INVOKE_ARGV[@]+"${_LOKI_INVOKE_ARGV[@]}"}" 2>/dev/null)
|
|
3160
|
+
_provider_rc=$?
|
|
3161
|
+
fi
|
|
3162
|
+
;;
|
|
3138
3163
|
esac
|
|
3139
3164
|
|
|
3140
3165
|
# bash-F4 (WAVE10 SAFE-DEFAULT): a provider timeout (124, incl. 128+SIGTERM
|
|
@@ -3303,6 +3328,22 @@ REASON: your reasoning"
|
|
|
3303
3328
|
verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
|
|
3304
3329
|
fi
|
|
3305
3330
|
;;
|
|
3331
|
+
*)
|
|
3332
|
+
# v8.2.0 TIMEOUT SEAM (contrarian / devil's-advocate vote). Same
|
|
3333
|
+
# rationale as the member vote: a real argv keeps the `timeout`
|
|
3334
|
+
# bound that a shell function would silently remove.
|
|
3335
|
+
#
|
|
3336
|
+
# SEMANTICS PRESERVED: this path tracks no _provider_rc by design --
|
|
3337
|
+
# an empty verdict (timeout or failure) already routes to the
|
|
3338
|
+
# conservative REJECT fallback immediately below.
|
|
3339
|
+
if type provider_invoke_argv >/dev/null 2>&1; then
|
|
3340
|
+
provider_invoke_argv fast "$prompt"
|
|
3341
|
+
# caveman HARD-SUPPRESS: parsed for "VOTE:".
|
|
3342
|
+
verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" \
|
|
3343
|
+
env CAVEMAN_DEFAULT_MODE=off \
|
|
3344
|
+
"${_LOKI_INVOKE_ARGV[@]+"${_LOKI_INVOKE_ARGV[@]}"}" 2>/dev/null)
|
|
3345
|
+
fi
|
|
3346
|
+
;;
|
|
3306
3347
|
esac
|
|
3307
3348
|
|
|
3308
3349
|
if [ -z "$verdict" ]; then
|
package/autonomy/council-v2.sh
CHANGED
|
@@ -441,7 +441,29 @@ except Exception:
|
|
|
441
441
|
fi
|
|
442
442
|
;;
|
|
443
443
|
*)
|
|
444
|
-
|
|
444
|
+
# v8.2.0 TIMEOUT SEAM. A provider exposing provider_invoke_argv gets
|
|
445
|
+
# a real reviewer instead of an automatic INCONCLUSIVE. The argv is a
|
|
446
|
+
# REAL command so `timeout` genuinely bounds it (providers/claude.sh:321);
|
|
447
|
+
# routing this through a shell function would silently drop the bound.
|
|
448
|
+
#
|
|
449
|
+
# VERDICT SEMANTICS UNCHANGED: on empty output, non-zero exit, or a
|
|
450
|
+
# timeout kill (124/137/143) we fall back to the SAME INCONCLUSIVE
|
|
451
|
+
# string used below -- never REJECT. A judge that produced no
|
|
452
|
+
# judgement has not rejected anything (v8.1.0 TRUST-3).
|
|
453
|
+
result=''
|
|
454
|
+
if type provider_invoke_argv >/dev/null 2>&1; then
|
|
455
|
+
local _c2_seam_rc=0
|
|
456
|
+
provider_invoke_argv fast "$full_prompt"
|
|
457
|
+
# caveman HARD-SUPPRESS: this verdict is parsed for the JSON
|
|
458
|
+
# "verdict" field; compression would reword it.
|
|
459
|
+
result="$(timeout "${LOKI_SDK_REVIEW_TIMEOUT:-180}" \
|
|
460
|
+
env CAVEMAN_DEFAULT_MODE=off \
|
|
461
|
+
"${_LOKI_INVOKE_ARGV[@]+"${_LOKI_INVOKE_ARGV[@]}"}" 2>/dev/null)" || _c2_seam_rc=$?
|
|
462
|
+
[ "$_c2_seam_rc" -ne 0 ] && result=''
|
|
463
|
+
fi
|
|
464
|
+
if [ -z "$result" ]; then
|
|
465
|
+
result='{"verdict":"INCONCLUSIVE","reasoning":"review not supported for this provider (no verdict obtained; NOT a rejection)","issues":[]}'
|
|
466
|
+
fi
|
|
445
467
|
;;
|
|
446
468
|
esac
|
|
447
469
|
|
|
@@ -452,6 +474,46 @@ except Exception:
|
|
|
452
474
|
# Try removing markdown fencing
|
|
453
475
|
extracted=$(echo "$result" | sed 's/^```json//;s/^```//' | sed -n '/^{/,/^}/p' | head -50)
|
|
454
476
|
fi
|
|
477
|
+
# STRUCTURE-TOLERANT RECOVERY (v8.2.0).
|
|
478
|
+
#
|
|
479
|
+
# A strict JSON carve is the single most model-sensitive contract in the
|
|
480
|
+
# engine: schema adherence is exactly what varies most across models, while
|
|
481
|
+
# every coding model can state a verdict in prose. Measured elsewhere (Forge
|
|
482
|
+
# replication): scaffolding drove tool-call errors 42 -> 0 while
|
|
483
|
+
# advanced-reasoning accuracy stayed flat -- i.e. a harness CAN rescue
|
|
484
|
+
# format compliance but cannot manufacture judgment. So recovering a verdict
|
|
485
|
+
# the model genuinely expressed is legitimate; inventing one is not.
|
|
486
|
+
#
|
|
487
|
+
# This runs ONLY when the JSON carve produced nothing, and it accepts a
|
|
488
|
+
# verdict only when the model stated it UNAMBIGUOUSLY (exactly one of
|
|
489
|
+
# APPROVE/REJECT appears as a standalone word). A response mentioning both,
|
|
490
|
+
# or neither, stays INCONCLUSIVE -- never guessed.
|
|
491
|
+
if [ -z "$extracted" ] && [ -n "${result:-}" ]; then
|
|
492
|
+
local _recovered
|
|
493
|
+
_recovered="$(printf '%s' "$result" | _LOKI_RAW="$result" python3 -c '
|
|
494
|
+
import os, re, sys, json
|
|
495
|
+
raw = os.environ.get("_LOKI_RAW", "")
|
|
496
|
+
# Standalone words only: "APPROVE" not "approved-by", and not inside a URL.
|
|
497
|
+
approve = len(re.findall(r"(?<![A-Za-z0-9_-])APPROVE(?![A-Za-z0-9_-])", raw, re.I))
|
|
498
|
+
reject = len(re.findall(r"(?<![A-Za-z0-9_-])REJECT(?![A-Za-z0-9_-])", raw, re.I))
|
|
499
|
+
if approve and not reject:
|
|
500
|
+
v = "APPROVE"
|
|
501
|
+
elif reject and not approve:
|
|
502
|
+
v = "REJECT"
|
|
503
|
+
else:
|
|
504
|
+
sys.exit(1) # ambiguous or absent -> stay inconclusive
|
|
505
|
+
print(json.dumps({
|
|
506
|
+
"verdict": v,
|
|
507
|
+
"reasoning": "recovered from unstructured output (model stated %s in prose)" % v,
|
|
508
|
+
"issues": [],
|
|
509
|
+
"recovered": True,
|
|
510
|
+
}))
|
|
511
|
+
' 2>/dev/null)" || _recovered=""
|
|
512
|
+
if [ -n "$_recovered" ]; then
|
|
513
|
+
extracted="$_recovered"
|
|
514
|
+
fi
|
|
515
|
+
fi
|
|
516
|
+
|
|
455
517
|
if [ -z "$extracted" ]; then
|
|
456
518
|
# The weak-model case, and the reason this matters most: a model whose
|
|
457
519
|
# prose could not be carved into JSON did not vote REJECT. Recording one
|
package/autonomy/grill.sh
CHANGED
|
@@ -158,6 +158,12 @@ grill_check_provider() {
|
|
|
158
158
|
fi
|
|
159
159
|
;;
|
|
160
160
|
*)
|
|
161
|
+
# v8.2.0: capability, not identity. Must stay in lockstep with the
|
|
162
|
+
# matching arm in grill_invoke_provider -- this gate runs FIRST, so
|
|
163
|
+
# rejecting here would make that arm dead code.
|
|
164
|
+
if type provider_invoke_argv >/dev/null 2>&1; then
|
|
165
|
+
return 0
|
|
166
|
+
fi
|
|
161
167
|
_grill_err "grill currently supports the claude and codex providers (got: $provider)"
|
|
162
168
|
return $GRILL_EXIT_ERROR
|
|
163
169
|
;;
|
|
@@ -278,6 +284,32 @@ grill_invoke_provider() {
|
|
|
278
284
|
return 0
|
|
279
285
|
;;
|
|
280
286
|
*)
|
|
287
|
+
# v8.2.0 TIMEOUT SEAM. Previously any other provider was a hard
|
|
288
|
+
# error. A provider exposing provider_invoke_argv builds a REAL argv
|
|
289
|
+
# (not a shell function), so _grill_with_timeout still bounds it --
|
|
290
|
+
# the whole reason the seam exists (providers/claude.sh:321).
|
|
291
|
+
# Nothing to preserve on this arm, so nothing can regress: it
|
|
292
|
+
# produced no output at all before.
|
|
293
|
+
#
|
|
294
|
+
# Deliberate: no --disallowedTools. That flag is claude-specific with
|
|
295
|
+
# no portable equivalent, so a seam-provider grill CAN write to the
|
|
296
|
+
# tree. Accepted; the alternative is the hard error below.
|
|
297
|
+
if type provider_invoke_argv >/dev/null 2>&1; then
|
|
298
|
+
local out
|
|
299
|
+
provider_invoke_argv fast "$prompt"
|
|
300
|
+
# env CAVEMAN_DEFAULT_MODE=off: grill output is parsed downstream
|
|
301
|
+
# and caveman compression would reword the questions. `env` (not a
|
|
302
|
+
# bare prefix) because _grill_with_timeout execs its first token.
|
|
303
|
+
out="$(_grill_with_timeout "${LOKI_GRILL_TIMEOUT:-180}" \
|
|
304
|
+
env CAVEMAN_DEFAULT_MODE=off \
|
|
305
|
+
"${_LOKI_INVOKE_ARGV[@]+"${_LOKI_INVOKE_ARGV[@]}"}" 2>/dev/null)"
|
|
306
|
+
if [ -z "$out" ]; then
|
|
307
|
+
_grill_err "provider returned no output (timeout or invocation error)"
|
|
308
|
+
return $GRILL_EXIT_ERROR
|
|
309
|
+
fi
|
|
310
|
+
printf '%s\n' "$out"
|
|
311
|
+
return 0
|
|
312
|
+
fi
|
|
281
313
|
_grill_err "grill currently supports the claude and codex providers (got: $provider)"
|
|
282
314
|
return $GRILL_EXIT_ERROR
|
|
283
315
|
;;
|
|
@@ -159,12 +159,24 @@ except Exception:
|
|
|
159
159
|
return 0
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
# Decide whether model verification can be attempted.
|
|
163
|
-
#
|
|
164
|
-
#
|
|
162
|
+
# Decide whether model verification can be attempted.
|
|
163
|
+
#
|
|
164
|
+
# v8.2.0: this used to require LOKI_PROVIDER=claude specifically, so a user on
|
|
165
|
+
# codex/opencode/cline/aider silently lost done-recognition. That is a
|
|
166
|
+
# CAPABILITY question, not an identity question -- the real requirement is "can
|
|
167
|
+
# we reach a model AND bound the call with a timeout". Any provider exposing the
|
|
168
|
+
# argv seam (provider_invoke_argv, see providers/claude.sh) satisfies both.
|
|
169
|
+
#
|
|
170
|
+
# Falls back to the historical claude-binary check when the seam is absent, so
|
|
171
|
+
# nothing regresses for existing installs.
|
|
165
172
|
_loki_done_recog_provider_ok() {
|
|
166
|
-
[ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
|
|
167
173
|
[ "${PROVIDER_DEGRADED:-false}" != "true" ] || return 1
|
|
174
|
+
# Preferred: a provider that can build a timeout-able argv.
|
|
175
|
+
if type provider_invoke_argv >/dev/null 2>&1; then
|
|
176
|
+
return 0
|
|
177
|
+
fi
|
|
178
|
+
# Legacy path: claude binary present.
|
|
179
|
+
[ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
|
|
168
180
|
command -v claude >/dev/null 2>&1 || return 1
|
|
169
181
|
return 0
|
|
170
182
|
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fast_verify -- millisecond-scale deterministic verification.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS
|
|
5
|
+
Verification that takes minutes does not scale and cannot be embedded. To be
|
|
6
|
+
pluggable -- into an IDE, an MCP tool, a CI step, or another vendor's agent --
|
|
7
|
+
a verdict has to come back in the time a human would wait for a keystroke,
|
|
8
|
+
not a coffee break.
|
|
9
|
+
|
|
10
|
+
MEASURED BASELINE on this repo before this module:
|
|
11
|
+
tests/detect-mock-problems.sh 11,040 ms
|
|
12
|
+
tests/detect-test-mutations.sh 12,361 ms
|
|
13
|
+
|
|
14
|
+
The work itself is not slow. The ARCHITECTURE was:
|
|
15
|
+
1. FOUR separate full-tree `find` walks in one detector (one walk = 110 ms).
|
|
16
|
+
2. ~25 subprocess spawns at ~24 ms each of pure interpreter startup.
|
|
17
|
+
3. Every detector re-discovering the same file set independently.
|
|
18
|
+
|
|
19
|
+
`git ls-files` returns the same set in 37 ms, already deduplicated and
|
|
20
|
+
already gitignore-aware. So the fix is not "optimize the shell" -- it is to
|
|
21
|
+
stop paying discovery and process tax N times.
|
|
22
|
+
|
|
23
|
+
THE FIVE DESIGN RULES
|
|
24
|
+
A. SINGLE PASS - walk once, classify once, hand each detector its slice.
|
|
25
|
+
B. ZERO SUBPROCESS - detectors are pure functions in ONE process. Startup is
|
|
26
|
+
paid once, not 25 times.
|
|
27
|
+
C. CONTENT-ADDRESSED- cache each file's findings by content hash. An unchanged
|
|
28
|
+
file is never re-read. Warm runs approach cache-read cost.
|
|
29
|
+
D. DIFF-SCOPED - verifying a change needs the changed files, not the repo.
|
|
30
|
+
E. EXOGENOUS ONLY - no LLM on this path, ever. Only checks the agent cannot
|
|
31
|
+
author or influence: deterministic, reproducible, and
|
|
32
|
+
therefore trustworthy. That constraint is what makes the
|
|
33
|
+
fast path both fast AND the thing worth trusting.
|
|
34
|
+
|
|
35
|
+
WHAT THIS DELIBERATELY DOES NOT DO
|
|
36
|
+
It does not run the test suite, call a model, or render judgment. Those are
|
|
37
|
+
slower and, in the case of model judgment, not exogenous. This module answers
|
|
38
|
+
only the question a machine can answer deterministically, which is exactly the
|
|
39
|
+
question worth answering in milliseconds.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
import time
|
|
51
|
+
from dataclasses import dataclass, field, asdict
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Iterable
|
|
54
|
+
|
|
55
|
+
SCHEMA_VERSION = 1
|
|
56
|
+
|
|
57
|
+
# Source extensions worth scanning. Anything else is discovery noise.
|
|
58
|
+
CODE_EXT = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".rb", ".java", ".sh"}
|
|
59
|
+
TEST_RE = re.compile(r"(^|/)(test_[^/]+\.py|[^/]+\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs))$")
|
|
60
|
+
|
|
61
|
+
# Excluded even when git tracks them: vendored or generated trees produce findings
|
|
62
|
+
# nobody can act on, and scanning them is pure latency.
|
|
63
|
+
EXCLUDE_RE = re.compile(r"(^|/)(node_modules|dist|build|vendor|\.venv|coverage|__pycache__)(/|$)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- Findings ---------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Finding:
|
|
70
|
+
rule: str
|
|
71
|
+
path: str
|
|
72
|
+
line: int
|
|
73
|
+
message: str
|
|
74
|
+
severity: str = "high"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Result:
|
|
79
|
+
verdict: str # PASS | FAIL | INCONCLUSIVE
|
|
80
|
+
findings: list = field(default_factory=list)
|
|
81
|
+
files_scanned: int = 0
|
|
82
|
+
files_from_cache: int = 0
|
|
83
|
+
elapsed_ms: float = 0.0
|
|
84
|
+
schema_version: int = SCHEMA_VERSION
|
|
85
|
+
exogenous: bool = True # never set False; this path admits no LLM
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Rule A: mock data rendered as if it were real ---------------------------
|
|
89
|
+
# A UI that maps over a hardcoded array and renders it is the "Potemkin
|
|
90
|
+
# interface" failure -- it looks finished and is not wired to anything.
|
|
91
|
+
|
|
92
|
+
_INLINE_COLLECTION = re.compile(
|
|
93
|
+
r"""(?:const|let|var)\s+(\w+)\s*=\s*\[\s*\{""", re.M)
|
|
94
|
+
_RENDER_MAP = re.compile(r"""\{?\s*(\w+)\s*(?:\?\.)?\.map\s*\(""")
|
|
95
|
+
_FETCHY = re.compile(r"\b(fetch|axios|useQuery|useSWR|supabase|prisma|createClient)\b")
|
|
96
|
+
|
|
97
|
+
# Names that are legitimately static content, not stand-ins for a backend.
|
|
98
|
+
_STATIC_OK = re.compile(
|
|
99
|
+
r"^(features?|benefits?|faqs?|steps?|tabs?|nav|navigation|menu|links?|routes?|"
|
|
100
|
+
r"columns?|options?|plans?|pricing|tiers?|testimonials?|stats?|socials?|icons?)$",
|
|
101
|
+
re.I)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _rule_mock_render(path: str, text: str) -> list:
|
|
105
|
+
out = []
|
|
106
|
+
if not text or "[" not in text:
|
|
107
|
+
return out
|
|
108
|
+
declared = {}
|
|
109
|
+
for m in _INLINE_COLLECTION.finditer(text):
|
|
110
|
+
declared[m.group(1)] = text[:m.start()].count("\n") + 1
|
|
111
|
+
if not declared:
|
|
112
|
+
return out
|
|
113
|
+
# A real data source in the same file means the array is plausibly a fallback,
|
|
114
|
+
# not the product. Stay quiet rather than cry wolf: a false BLOCK on a correct
|
|
115
|
+
# build is worse than a missed warning, because it trains users to ignore us.
|
|
116
|
+
if _FETCHY.search(text):
|
|
117
|
+
return out
|
|
118
|
+
for m in _RENDER_MAP.finditer(text):
|
|
119
|
+
name = m.group(1)
|
|
120
|
+
if name in declared and not _STATIC_OK.match(name):
|
|
121
|
+
out.append(Finding(
|
|
122
|
+
rule="mock_render",
|
|
123
|
+
path=path,
|
|
124
|
+
line=declared[name],
|
|
125
|
+
message=(f"'{name}' is a hardcoded collection rendered directly; "
|
|
126
|
+
"no data source found in this file"),
|
|
127
|
+
))
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Rule B: tests that cannot fail ------------------------------------------
|
|
132
|
+
# A test with no assertion is worse than no test: it turns a green suite into a
|
|
133
|
+
# false claim, which is precisely the lie this product exists to prevent.
|
|
134
|
+
|
|
135
|
+
_SKIPPED = re.compile(r"\b(it|test|describe)\.(skip|todo)\s*\(|@(unittest\.)?skip\b")
|
|
136
|
+
_HAS_ASSERT = re.compile(r"\b(expect|assert|should|chai|sinon\.assert)\b")
|
|
137
|
+
_TEST_BLOCK = re.compile(r"""\b(?:it|test)\s*\(\s*['"`]([^'"`]{1,120})['"`]""")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _rule_toothless_test(path: str, text: str) -> list:
|
|
141
|
+
out = []
|
|
142
|
+
if not text:
|
|
143
|
+
return out
|
|
144
|
+
for m in _SKIPPED.finditer(text):
|
|
145
|
+
out.append(Finding(
|
|
146
|
+
rule="skipped_test", path=path,
|
|
147
|
+
line=text[:m.start()].count("\n") + 1,
|
|
148
|
+
message="test is skipped; it cannot fail and cannot verify anything",
|
|
149
|
+
severity="medium",
|
|
150
|
+
))
|
|
151
|
+
blocks = list(_TEST_BLOCK.finditer(text))
|
|
152
|
+
for i, m in enumerate(blocks):
|
|
153
|
+
end = blocks[i + 1].start() if i + 1 < len(blocks) else len(text)
|
|
154
|
+
body = text[m.end():end]
|
|
155
|
+
if not _HAS_ASSERT.search(body):
|
|
156
|
+
out.append(Finding(
|
|
157
|
+
rule="assertionless_test", path=path,
|
|
158
|
+
line=text[:m.start()].count("\n") + 1,
|
|
159
|
+
message=f"test '{m.group(1)[:60]}' contains no assertion; it always passes",
|
|
160
|
+
))
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
RULES = (_rule_mock_render, _rule_toothless_test)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- Discovery: one pass, from the git index ---------------------------------
|
|
168
|
+
|
|
169
|
+
def _git(args: list, cwd: str) -> str:
|
|
170
|
+
try:
|
|
171
|
+
p = subprocess.run(["git"] + args, cwd=cwd, capture_output=True,
|
|
172
|
+
text=True, timeout=20)
|
|
173
|
+
return p.stdout if p.returncode == 0 else ""
|
|
174
|
+
except (OSError, subprocess.SubprocessError):
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def discover(root: str, diff_base: str = "") -> list:
|
|
179
|
+
"""Return candidate files. ONE listing, no per-detector walk.
|
|
180
|
+
|
|
181
|
+
`git ls-files` beats `find` on every axis that matters here: it reads the
|
|
182
|
+
index instead of stat-ing the tree (37 ms vs 110 ms measured), it is already
|
|
183
|
+
gitignore-aware, and it never descends into node_modules. When a diff base is
|
|
184
|
+
given we narrow further -- verifying a change does not require reading the
|
|
185
|
+
repository.
|
|
186
|
+
"""
|
|
187
|
+
if diff_base:
|
|
188
|
+
raw = _git(["diff", "--name-only", diff_base + "...HEAD"], root)
|
|
189
|
+
if not raw.strip():
|
|
190
|
+
raw = _git(["diff", "--name-only", diff_base], root)
|
|
191
|
+
else:
|
|
192
|
+
raw = _git(["ls-files"], root)
|
|
193
|
+
|
|
194
|
+
if not raw:
|
|
195
|
+
# Not a git repo (or an empty one): fall back to a single os.walk. Still
|
|
196
|
+
# one pass -- the guarantee holds even off the happy path.
|
|
197
|
+
files = []
|
|
198
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
199
|
+
dirnames[:] = [d for d in dirnames
|
|
200
|
+
if not EXCLUDE_RE.search(d) and not d.startswith(".")]
|
|
201
|
+
for fn in filenames:
|
|
202
|
+
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
|
203
|
+
if Path(fn).suffix in CODE_EXT and not EXCLUDE_RE.search(rel):
|
|
204
|
+
files.append(rel)
|
|
205
|
+
return files
|
|
206
|
+
|
|
207
|
+
return [ln for ln in raw.splitlines()
|
|
208
|
+
if ln and Path(ln).suffix in CODE_EXT and not EXCLUDE_RE.search(ln)]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --- Content-addressed cache -------------------------------------------------
|
|
212
|
+
|
|
213
|
+
class Cache:
|
|
214
|
+
"""Findings keyed by content hash, so unchanged files are never re-read.
|
|
215
|
+
|
|
216
|
+
Correctness note: the key is the file's CONTENT, not its path or mtime. A
|
|
217
|
+
file that moves keeps its result; a file that changes gets a new key. There
|
|
218
|
+
is no staleness window to reason about, which is what makes it safe to trust
|
|
219
|
+
a cache hit as if the scan had just run.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, path: str):
|
|
223
|
+
self.path = path
|
|
224
|
+
self.data = {}
|
|
225
|
+
self.hits = 0
|
|
226
|
+
try:
|
|
227
|
+
with open(path, encoding="utf-8") as fh:
|
|
228
|
+
blob = json.load(fh)
|
|
229
|
+
if isinstance(blob, dict) and blob.get("schema") == SCHEMA_VERSION:
|
|
230
|
+
self.data = blob.get("entries", {})
|
|
231
|
+
except (OSError, ValueError):
|
|
232
|
+
self.data = {}
|
|
233
|
+
|
|
234
|
+
def get(self, key: str):
|
|
235
|
+
v = self.data.get(key)
|
|
236
|
+
if v is not None:
|
|
237
|
+
self.hits += 1
|
|
238
|
+
return v
|
|
239
|
+
|
|
240
|
+
def put(self, key: str, findings: list) -> None:
|
|
241
|
+
self.data[key] = findings
|
|
242
|
+
|
|
243
|
+
def save(self) -> None:
|
|
244
|
+
try:
|
|
245
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
246
|
+
tmp = self.path + ".tmp"
|
|
247
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
248
|
+
json.dump({"schema": SCHEMA_VERSION, "entries": self.data}, fh)
|
|
249
|
+
os.replace(tmp, self.path)
|
|
250
|
+
except OSError:
|
|
251
|
+
pass # a cache that cannot be written must never break a verdict
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --- The entry point ---------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def verify(root: str = ".", diff_base: str = "", use_cache: bool = True) -> Result:
|
|
257
|
+
t0 = time.perf_counter()
|
|
258
|
+
root = os.path.abspath(root)
|
|
259
|
+
cache = Cache(os.path.join(root, ".loki", "cache", "fast-verify.json")) if use_cache else None
|
|
260
|
+
|
|
261
|
+
findings = []
|
|
262
|
+
scanned = 0
|
|
263
|
+
|
|
264
|
+
for rel in discover(root, diff_base):
|
|
265
|
+
full = os.path.join(root, rel)
|
|
266
|
+
try:
|
|
267
|
+
with open(full, "rb") as fh:
|
|
268
|
+
raw = fh.read()
|
|
269
|
+
except OSError:
|
|
270
|
+
continue
|
|
271
|
+
scanned += 1
|
|
272
|
+
|
|
273
|
+
key = hashlib.blake2b(raw, digest_size=16).hexdigest()
|
|
274
|
+
if cache is not None:
|
|
275
|
+
hit = cache.get(key)
|
|
276
|
+
if hit is not None:
|
|
277
|
+
for d in hit:
|
|
278
|
+
findings.append(Finding(**{**d, "path": rel}))
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
text = raw.decode("utf-8", errors="replace")
|
|
283
|
+
except Exception:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
is_test = bool(TEST_RE.search(rel))
|
|
287
|
+
got = []
|
|
288
|
+
for rule in RULES:
|
|
289
|
+
# Rule B is about tests; rule A is about product code. Running each
|
|
290
|
+
# only where it applies is not just faster, it removes a whole class
|
|
291
|
+
# of false positive.
|
|
292
|
+
if rule is _rule_toothless_test and not is_test:
|
|
293
|
+
continue
|
|
294
|
+
if rule is _rule_mock_render and is_test:
|
|
295
|
+
continue
|
|
296
|
+
got.extend(rule(rel, text))
|
|
297
|
+
|
|
298
|
+
if cache is not None:
|
|
299
|
+
cache.put(key, [{k: v for k, v in asdict(f).items() if k != "path"} for f in got])
|
|
300
|
+
findings.extend(got)
|
|
301
|
+
|
|
302
|
+
if cache is not None:
|
|
303
|
+
cache.save()
|
|
304
|
+
|
|
305
|
+
blocking = [f for f in findings if f.severity == "high"]
|
|
306
|
+
verdict = "FAIL" if blocking else ("PASS" if scanned else "INCONCLUSIVE")
|
|
307
|
+
|
|
308
|
+
return Result(
|
|
309
|
+
verdict=verdict,
|
|
310
|
+
findings=[asdict(f) for f in findings],
|
|
311
|
+
files_scanned=scanned,
|
|
312
|
+
files_from_cache=(cache.hits if cache else 0),
|
|
313
|
+
elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main(argv: list) -> int:
|
|
318
|
+
root, base, use_cache, as_json = ".", "", True, False
|
|
319
|
+
i = 0
|
|
320
|
+
while i < len(argv):
|
|
321
|
+
a = argv[i]
|
|
322
|
+
if a == "--diff-base" and i + 1 < len(argv):
|
|
323
|
+
base = argv[i + 1]; i += 1
|
|
324
|
+
elif a == "--no-cache":
|
|
325
|
+
use_cache = False
|
|
326
|
+
elif a == "--json":
|
|
327
|
+
as_json = True
|
|
328
|
+
elif not a.startswith("-"):
|
|
329
|
+
root = a
|
|
330
|
+
i += 1
|
|
331
|
+
|
|
332
|
+
r = verify(root, base, use_cache)
|
|
333
|
+
if as_json:
|
|
334
|
+
print(json.dumps(asdict(r), indent=2))
|
|
335
|
+
else:
|
|
336
|
+
print(f"{r.verdict} in {r.elapsed_ms}ms "
|
|
337
|
+
f"({r.files_scanned} files, {r.files_from_cache} cached)")
|
|
338
|
+
for f in r.findings[:20]:
|
|
339
|
+
print(f" [{f['severity']}] {f['rule']} {f['path']}:{f['line']} - {f['message']}")
|
|
340
|
+
if len(r.findings) > 20:
|
|
341
|
+
print(f" ... and {len(r.findings) - 20} more")
|
|
342
|
+
return 1 if r.verdict == "FAIL" else 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import sys
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Iterable
|
|
@@ -753,7 +754,26 @@ def scan(files: list[str], tree: Path) -> tuple[int, dict[str, object] | None]:
|
|
|
753
754
|
|
|
754
755
|
|
|
755
756
|
def main() -> int:
|
|
756
|
-
|
|
757
|
+
# Transport: stdin first, _NM_FILES as fallback. An env string is capped at
|
|
758
|
+
# MAX_ARG_STRLEN (131071 bytes) on Linux, so a large changed-file list makes
|
|
759
|
+
# execve fail with E2BIG -- the caller then captures its own
|
|
760
|
+
# "INCONCLUSIVE:detector_error" fallback and the gate silently passes
|
|
761
|
+
# through. macOS has no per-string cap, which is why that failure was
|
|
762
|
+
# Linux-only. stdin has no such limit.
|
|
763
|
+
# Read defensively: a caller may close stdin (sys.stdin is then None) or
|
|
764
|
+
# hand over a tty. Either way fall back to the env var rather than raising,
|
|
765
|
+
# because an uncaught error here reads to the caller as
|
|
766
|
+
# "INCONCLUSIVE:detector_error" -- the exact silent pass-through this
|
|
767
|
+
# transport change exists to remove.
|
|
768
|
+
raw = ""
|
|
769
|
+
try:
|
|
770
|
+
if sys.stdin is not None and not sys.stdin.isatty():
|
|
771
|
+
raw = sys.stdin.read()
|
|
772
|
+
except (OSError, ValueError):
|
|
773
|
+
raw = ""
|
|
774
|
+
if not raw.strip():
|
|
775
|
+
raw = os.environ.get("_NM_FILES", "")
|
|
776
|
+
files = [line.strip() for line in raw.splitlines() if line.strip()]
|
|
757
777
|
tree = Path(os.environ.get("_NM_TREE", ".")).resolve()
|
|
758
778
|
output = os.environ.get("_NM_OUT", "")
|
|
759
779
|
scanned, hit = scan(files, tree)
|