loki-mode 8.37.0 → 8.38.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +6 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tests/detect-invariant-violations.sh +359 -0
- package/tests/detect-mock-problems.sh +473 -0
- package/tests/detect-semantic-test-problems.sh +332 -0
- package/tests/detect-test-mutations.sh +455 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Mock Detector - Quality Gate #8
|
|
3
|
+
# Scans test files for problematic mock patterns that mask real failures
|
|
4
|
+
#
|
|
5
|
+
# Usage: ./tests/detect-mock-problems.sh [--strict]
|
|
6
|
+
# --strict: Exit with error code on any finding (for CI)
|
|
7
|
+
#
|
|
8
|
+
# Detects:
|
|
9
|
+
# 1. Tests that define inline functions and test them instead of importing real code
|
|
10
|
+
# 2. Tautological assertions (assert on literal values)
|
|
11
|
+
# 3. Conditional assertions that silently pass (if guards around expects)
|
|
12
|
+
# 4. Empty test bodies
|
|
13
|
+
# 5. Tests with no imports from source code
|
|
14
|
+
# 6. Internal mock ratio: mocks of own code vs external service mocks
|
|
15
|
+
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
# Directory to scan. Defaults to the repo containing this script (so
|
|
20
|
+
# run-all-tests.sh keeps scanning loki-mode unchanged). A run.sh gate wrapper
|
|
21
|
+
# MUST set LOKI_SCAN_DIR to the target project; cwd is NOT used by find here,
|
|
22
|
+
# so `cd TARGET_DIR` alone does not redirect the scan.
|
|
23
|
+
PROJECT_DIR="${LOKI_SCAN_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
24
|
+
STRICT="${1:-}"
|
|
25
|
+
|
|
26
|
+
RED='\033[0;31m'
|
|
27
|
+
YELLOW='\033[1;33m'
|
|
28
|
+
GREEN='\033[0;32m'
|
|
29
|
+
CYAN='\033[0;36m'
|
|
30
|
+
NC='\033[0m'
|
|
31
|
+
|
|
32
|
+
CRITICAL=0
|
|
33
|
+
HIGH=0
|
|
34
|
+
MEDIUM=0
|
|
35
|
+
LOW=0
|
|
36
|
+
|
|
37
|
+
echo "=========================================="
|
|
38
|
+
echo "Mock Detector - Quality Gate #8"
|
|
39
|
+
echo "=========================================="
|
|
40
|
+
echo ""
|
|
41
|
+
|
|
42
|
+
report() {
|
|
43
|
+
local severity="$1"
|
|
44
|
+
local file="$2"
|
|
45
|
+
local line="$3"
|
|
46
|
+
local message="$4"
|
|
47
|
+
|
|
48
|
+
case "$severity" in
|
|
49
|
+
CRITICAL) echo -e "${RED}[CRITICAL]${NC} $file:$line - $message"; ((CRITICAL++)) ;;
|
|
50
|
+
HIGH) echo -e "${RED}[HIGH]${NC} $file:$line - $message"; ((HIGH++)) ;;
|
|
51
|
+
MEDIUM) echo -e "${YELLOW}[MEDIUM]${NC} $file:$line - $message"; ((MEDIUM++)) ;;
|
|
52
|
+
LOW) echo -e "${CYAN}[LOW]${NC} $file:$line - $message"; ((LOW++)) ;;
|
|
53
|
+
esac
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
local_import_is_source() {
|
|
57
|
+
local test_file="$1"
|
|
58
|
+
local spec="$2"
|
|
59
|
+
local normalized leaf test_dir import_base candidate
|
|
60
|
+
|
|
61
|
+
spec="${spec%%\?*}"
|
|
62
|
+
spec="${spec%%#*}"
|
|
63
|
+
# Bash's own lowercase expansion (4.0+). The `printf | tr` it replaces cost
|
|
64
|
+
# TWO forks per import spec -- 149 subprocesses on this repo -- to do what
|
|
65
|
+
# the shell does natively. Falls back to tr on bash 3.2 (stock macOS), which
|
|
66
|
+
# lacks ${var,,}, so behavior is identical on every supported shell.
|
|
67
|
+
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
|
|
68
|
+
normalized="${spec,,}"
|
|
69
|
+
else
|
|
70
|
+
normalized=$(printf '%s' "$spec" | tr '[:upper:]' '[:lower:]')
|
|
71
|
+
fi
|
|
72
|
+
leaf="${normalized##*/}"
|
|
73
|
+
|
|
74
|
+
case "/$normalized/" in
|
|
75
|
+
*/__mocks__/*|*/mocks/*|*/mock/*|*/__tests__/*|*/tests/*|*/test/*|*/__fixtures__/*|*/fixtures/*|*/test-utils/*|*/test_utils/*|*/test-helpers/*|*/test_helpers/*)
|
|
76
|
+
return 1
|
|
77
|
+
;;
|
|
78
|
+
esac
|
|
79
|
+
case "$leaf" in
|
|
80
|
+
mock|mocks|mock.*|mocks.*|mock-*|mock_*|*.mock|*.mock.*|*.test|*.test.*|*.spec|*.spec.*|fixture|fixtures|fixture.*|fixtures.*|test-helper*|test_helper*|test-util*|test_util*|setup-tests*|setup_tests*|setuptests*)
|
|
81
|
+
return 1
|
|
82
|
+
;;
|
|
83
|
+
esac
|
|
84
|
+
|
|
85
|
+
test_dir="${test_file%/*}"
|
|
86
|
+
[ "$test_dir" != "$test_file" ] || test_dir="."
|
|
87
|
+
import_base="$test_dir/$spec"
|
|
88
|
+
|
|
89
|
+
case "$leaf" in
|
|
90
|
+
*.js|*.jsx|*.ts|*.tsx|*.mjs|*.cjs|*.mts|*.cts|*.vue|*.svelte)
|
|
91
|
+
[ -f "$import_base" ] && return 0
|
|
92
|
+
;;
|
|
93
|
+
*.*) ;;
|
|
94
|
+
*) [ -f "$import_base" ] && return 0 ;;
|
|
95
|
+
esac
|
|
96
|
+
|
|
97
|
+
for candidate in \
|
|
98
|
+
"$import_base.js" "$import_base.jsx" "$import_base.ts" "$import_base.tsx" \
|
|
99
|
+
"$import_base.mjs" "$import_base.cjs" "$import_base.mts" "$import_base.cts" \
|
|
100
|
+
"$import_base.vue" "$import_base.svelte" \
|
|
101
|
+
"$import_base/index.js" "$import_base/index.jsx" \
|
|
102
|
+
"$import_base/index.ts" "$import_base/index.tsx" \
|
|
103
|
+
"$import_base/index.mjs" "$import_base/index.cjs" \
|
|
104
|
+
"$import_base/index.mts" "$import_base/index.cts" \
|
|
105
|
+
"$import_base/index.vue" "$import_base/index.svelte"; do
|
|
106
|
+
[ -f "$candidate" ] && return 0
|
|
107
|
+
done
|
|
108
|
+
return 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test_has_source_import() {
|
|
112
|
+
local test_file="$1"
|
|
113
|
+
local line spec import_open=false
|
|
114
|
+
local esm_from_re="^[[:space:]]*import[[:space:]]+.*[[:space:]]from[[:space:]]*['\"](\.{1,2}/[^'\"]+)['\"]"
|
|
115
|
+
local esm_side_re="^[[:space:]]*import[[:space:]]*['\"](\.{1,2}/[^'\"]+)['\"]"
|
|
116
|
+
local esm_continue_re="^[[:space:]]*.*[[:space:]]from[[:space:]]*['\"](\.{1,2}/[^'\"]+)['\"]"
|
|
117
|
+
local cjs_re="require[[:space:]]*\([[:space:]]*['\"](\.{1,2}/[^'\"]+)['\"]"
|
|
118
|
+
# `require.resolve('./x')` names the real module just as `require('./x')`
|
|
119
|
+
# does -- it is how a subprocess/E2E test points at the source it runs.
|
|
120
|
+
# Separate pattern (not an alternation) so the path stays BASH_REMATCH[1].
|
|
121
|
+
local cjs_resolve_re="require\.resolve[[:space:]]*\([[:space:]]*['\"](\.{1,2}/[^'\"]+)['\"]"
|
|
122
|
+
# A test that hands a literal source path to a child process exercises the
|
|
123
|
+
# real code end-to-end without ever naming it in an import. Gated on the
|
|
124
|
+
# file importing child_process, because this flag unlocks a deliberately
|
|
125
|
+
# broad path-literal pattern -- keep the enabling condition as narrow as
|
|
126
|
+
# possible. You cannot spawn in Node without importing child_process, so
|
|
127
|
+
# matching spawnSync/execFile/fork( as well would add no true positives
|
|
128
|
+
# while letting a mock-only test that merely MENTIONS them unlock it.
|
|
129
|
+
# Two shapes, both gated on has_subprocess below:
|
|
130
|
+
# 1. an explicitly-relative path -- './greet.js', '../src/cli.js'
|
|
131
|
+
# 2. a BARE source filename -- path.join(__dirname, 'greet.js')
|
|
132
|
+
# Shape 2 is idiomatic Node for locating a sibling script to spawn, and it
|
|
133
|
+
# was the exact form a real benchmark build produced. Requiring a leading
|
|
134
|
+
# './' missed it and re-raised the false positive. The bare form demands a
|
|
135
|
+
# source-code EXTENSION so ordinary string literals ('utf8', 'Hello, Ada!',
|
|
136
|
+
# 'node') can never satisfy it, and local_import_is_source() still has to
|
|
137
|
+
# resolve the name to a real file on disk before it counts.
|
|
138
|
+
local subproc_re="['\"](\.{1,2}/[^'\"]+)['\"]"
|
|
139
|
+
local subproc_bare_re="['\"]([A-Za-z0-9_.-]+\.(js|jsx|mjs|cjs|ts|tsx|mts|cts|py|rb|go|sh))['\"]"
|
|
140
|
+
# Flags come from two repo-wide `grep -lE` lists built once (P1_CHILD_PROC /
|
|
141
|
+
# P1_SPAWN_CALL), not two greps per file. Identical patterns; membership is a
|
|
142
|
+
# newline-delimited substring test, which stays in-process.
|
|
143
|
+
local has_subprocess=false
|
|
144
|
+
case "$P1_CHILD_PROC" in
|
|
145
|
+
*$'\n'"$test_file"$'\n'*) has_subprocess=true ;;
|
|
146
|
+
esac
|
|
147
|
+
# Stricter companion flag: the file must actually CALL a spawn function, not
|
|
148
|
+
# merely import the module. Guards the deliberately broad bare-filename
|
|
149
|
+
# pattern below (see its comment for the mock-only case this rejects).
|
|
150
|
+
local has_spawn_call=false
|
|
151
|
+
case "$P1_SPAWN_CALL" in
|
|
152
|
+
*$'\n'"$test_file"$'\n'*) has_spawn_call=true ;;
|
|
153
|
+
esac
|
|
154
|
+
|
|
155
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
156
|
+
case "$line" in
|
|
157
|
+
*'jest.mock('*|*'vi.mock('*) continue ;;
|
|
158
|
+
esac
|
|
159
|
+
if [[ "$line" =~ ^[[:space:]]*(//|/\*|\*) ]]; then
|
|
160
|
+
continue
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
# Cheap pre-filter, and a PROVABLE SUPERSET of the branches below -- it
|
|
164
|
+
# can only skip lines that every branch would have rejected anyway.
|
|
165
|
+
# Justification, branch by branch:
|
|
166
|
+
# - the import_open continuation branches are excluded by requiring
|
|
167
|
+
# import_open=false here;
|
|
168
|
+
# - every remaining ESM/CJS branch (esm_from, esm_side, bare `import`,
|
|
169
|
+
# require, require.resolve) needs the literal "import" or "require";
|
|
170
|
+
# - the two broad path-literal branches (subproc_re, subproc_bare_re)
|
|
171
|
+
# are gated on has_subprocess / has_spawn_call, so requiring BOTH to
|
|
172
|
+
# be false here means neither could have fired.
|
|
173
|
+
# A glob `case` runs in-process; the 7 `[[ =~ ]]` below use variable
|
|
174
|
+
# patterns that bash recompiles on every line, which is the real cost on
|
|
175
|
+
# 43k lines. Skipping a line here is indistinguishable from evaluating
|
|
176
|
+
# all branches and matching none.
|
|
177
|
+
if [ "$import_open" = false ] && [ "$has_subprocess" = false ] && [ "$has_spawn_call" = false ]; then
|
|
178
|
+
case "$line" in
|
|
179
|
+
*import*|*require*) ;;
|
|
180
|
+
*) continue ;;
|
|
181
|
+
esac
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
spec=""
|
|
185
|
+
if [ "$import_open" = true ]; then
|
|
186
|
+
if [[ "$line" =~ $esm_continue_re ]]; then
|
|
187
|
+
spec="${BASH_REMATCH[1]}"
|
|
188
|
+
import_open=false
|
|
189
|
+
elif [[ "$line" == *';'* ]]; then
|
|
190
|
+
import_open=false
|
|
191
|
+
fi
|
|
192
|
+
elif [[ "$line" =~ ^[[:space:]]*import[[:space:]]+type[[:space:]] ]]; then
|
|
193
|
+
continue
|
|
194
|
+
elif [[ "$line" =~ $esm_from_re ]]; then
|
|
195
|
+
spec="${BASH_REMATCH[1]}"
|
|
196
|
+
elif [[ "$line" =~ $esm_side_re ]]; then
|
|
197
|
+
spec="${BASH_REMATCH[1]}"
|
|
198
|
+
elif [[ "$line" =~ ^[[:space:]]*import[[:space:]] ]]; then
|
|
199
|
+
# Only latch an UNFINISHED multi-line import. A single-line import of
|
|
200
|
+
# a NON-relative module (`import { execFileSync } from
|
|
201
|
+
# 'node:child_process';`) reaches this branch because no ./-relative
|
|
202
|
+
# pattern matched -- latching on it left import_open stuck true for
|
|
203
|
+
# the REST OF THE FILE, so every later line was swallowed by the
|
|
204
|
+
# import_open branch and the subprocess patterns never ran. Effect:
|
|
205
|
+
# an ESM subprocess test was flagged CRITICAL while the
|
|
206
|
+
# byte-identical CJS version passed. Latch only when the statement is
|
|
207
|
+
# genuinely incomplete (no from-clause and no terminator on the line).
|
|
208
|
+
if [[ ! "$line" =~ from[[:space:]]*[\'\"][^\'\"]+[\'\"] ]] && [[ "$line" != *';'* ]]; then
|
|
209
|
+
import_open=true
|
|
210
|
+
fi
|
|
211
|
+
elif [[ "$line" =~ $cjs_resolve_re ]]; then
|
|
212
|
+
spec="${BASH_REMATCH[1]}"
|
|
213
|
+
elif [[ "$line" =~ $cjs_re ]]; then
|
|
214
|
+
spec="${BASH_REMATCH[1]}"
|
|
215
|
+
elif [ "$has_subprocess" = true ] && [[ "$line" =~ $subproc_re ]]; then
|
|
216
|
+
spec="${BASH_REMATCH[1]}"
|
|
217
|
+
elif [ "$has_spawn_call" = true ] && [[ "$line" =~ $subproc_bare_re ]]; then
|
|
218
|
+
# Bare filename ('greet.js'), normalized so local_import_is_source()
|
|
219
|
+
# resolves it against the test's own directory.
|
|
220
|
+
#
|
|
221
|
+
# Gated on has_spawn_call, NOT has_subprocess. An adversarial review
|
|
222
|
+
# produced a mock-only test that imports child_process, assigns
|
|
223
|
+
# `const TARGET_NAME = 'greet.js'`, then asserts on an INLINE stub and
|
|
224
|
+
# never spawns anything -- importing alone let that pass, blinding a
|
|
225
|
+
# fail-closed gate. Requiring an actual spawn INVOCATION somewhere in
|
|
226
|
+
# the file keeps the legitimate shapes (path.join(__dirname,'x.js')
|
|
227
|
+
# assigned on one line and spawned on the next) while rejecting a file
|
|
228
|
+
# that merely imports the module.
|
|
229
|
+
spec="./${BASH_REMATCH[1]}"
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
if [ -n "$spec" ] && local_import_is_source "$test_file" "$spec"; then
|
|
233
|
+
return 0
|
|
234
|
+
fi
|
|
235
|
+
done < "$test_file"
|
|
236
|
+
return 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# File-set discovery. Each pattern below used to run its OWN full-tree `find`
|
|
240
|
+
# (four walks). The tree is walked ONCE here and sliced into the three DISTINCT
|
|
241
|
+
# sets the patterns actually used -- they are not interchangeable:
|
|
242
|
+
# SET_TEST_ONLY Pattern 1 -- *.test.* only, e2e excluded
|
|
243
|
+
# SET_TEST_SPEC Patterns 3,4,6 -- test + spec, no Python
|
|
244
|
+
# SET_WITH_PY Patterns 2,5 -- test + spec + test_*.py
|
|
245
|
+
# Collapsing them would silently change which findings are produced.
|
|
246
|
+
SET_WITH_PY=()
|
|
247
|
+
SET_TEST_SPEC=()
|
|
248
|
+
SET_TEST_ONLY=()
|
|
249
|
+
while IFS= read -r _f; do
|
|
250
|
+
[ -n "$_f" ] || continue
|
|
251
|
+
# A tree entry can name a file that vanished mid-scan; grep on a missing
|
|
252
|
+
# file yields no finding, so skip rather than report a phantom.
|
|
253
|
+
[ -f "$_f" ] || continue
|
|
254
|
+
SET_WITH_PY+=("$_f")
|
|
255
|
+
case "$_f" in *.py) continue ;; esac
|
|
256
|
+
SET_TEST_SPEC+=("$_f")
|
|
257
|
+
case "$_f" in
|
|
258
|
+
*.spec.ts|*.spec.tsx|*.spec.js|*.spec.jsx) continue ;;
|
|
259
|
+
*e2e*) continue ;;
|
|
260
|
+
esac
|
|
261
|
+
SET_TEST_ONLY+=("$_f")
|
|
262
|
+
done < <(find "$PROJECT_DIR" \( \
|
|
263
|
+
-name "*.test.ts" -o -name "*.test.tsx" -o -name "*.test.js" -o -name "*.test.jsx" \
|
|
264
|
+
-o -name "*.spec.ts" -o -name "*.spec.tsx" -o -name "*.spec.js" -o -name "*.spec.jsx" \
|
|
265
|
+
-o -name "test_*.py" \) 2>/dev/null | grep -v node_modules | grep -v dist)
|
|
266
|
+
|
|
267
|
+
# Run one grep over a whole file set and stream `path:lineno:text` back.
|
|
268
|
+
# THE STREAM IS THE ITERATION: grep -H already emits results grouped by file in
|
|
269
|
+
# file-list order, so a per-file loop plus a lookup table is redundant work. One
|
|
270
|
+
# fork per pattern instead of one per file (2,495 greps measured before).
|
|
271
|
+
# -H is mandatory: with a single-file list grep omits the path and the caller
|
|
272
|
+
# would parse the line number as the filename.
|
|
273
|
+
scan_lines() {
|
|
274
|
+
local pattern="$1"; shift
|
|
275
|
+
[ "$#" -gt 0 ] || return 0
|
|
276
|
+
printf '%s\0' "$@" | xargs -0 grep -nHE -- "$pattern" 2>/dev/null || true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Pattern 1: TypeScript/JavaScript tests that never import from source
|
|
280
|
+
# (excludes E2E/spec files which interact via browser, not imports)
|
|
281
|
+
echo -e "${CYAN}Scanning for tests that never import real code...${NC}"
|
|
282
|
+
# Two repo-wide flag lists + one test-count table, each ONE grep for the whole
|
|
283
|
+
# set. Wrapped in newlines so a `case` membership test cannot match a path that
|
|
284
|
+
# is merely a suffix of another.
|
|
285
|
+
P1_CHILD_PROC=$'\n'
|
|
286
|
+
P1_SPAWN_CALL=$'\n'
|
|
287
|
+
if [ "${#SET_TEST_ONLY[@]}" -gt 0 ]; then
|
|
288
|
+
P1_CHILD_PROC=$'\n'"$(printf '%s\0' "${SET_TEST_ONLY[@]}" | xargs -0 grep -lE -- "child_process" 2>/dev/null || true)"$'\n'
|
|
289
|
+
P1_SPAWN_CALL=$'\n'"$(printf '%s\0' "${SET_TEST_ONLY[@]}" | xargs -0 grep -lE -- "(spawnSync|spawn|execFile|execFileSync|execSync|exec|fork)[[:space:]]*\\(" 2>/dev/null || true)"$'\n'
|
|
290
|
+
fi
|
|
291
|
+
# Per-file test counts, one grep -cH for the whole set. `grep -c` prints a row
|
|
292
|
+
# for EVERY file including zeros, so this is a complete table.
|
|
293
|
+
P1_TEST_COUNT=$'\n'
|
|
294
|
+
if [ "${#SET_TEST_ONLY[@]}" -gt 0 ]; then
|
|
295
|
+
P1_TEST_COUNT=$'\n'"$(printf '%s\0' "${SET_TEST_ONLY[@]}" | xargs -0 grep -cHE -- '(it\(|test\(|describe\()' 2>/dev/null || true)"$'\n'
|
|
296
|
+
fi
|
|
297
|
+
while IFS= read -r test_file; do
|
|
298
|
+
rel_path="${test_file#$PROJECT_DIR/}"
|
|
299
|
+
|
|
300
|
+
# Resolve local imports so same-directory source counts, while imports of
|
|
301
|
+
# test helpers, fixtures, mocks, or missing modules do not.
|
|
302
|
+
has_source_import=false
|
|
303
|
+
test_has_source_import "$test_file" && has_source_import=true
|
|
304
|
+
|
|
305
|
+
if [ "$has_source_import" = false ]; then
|
|
306
|
+
# Count actual test cases
|
|
307
|
+
# Pull this file's count out of the precomputed table.
|
|
308
|
+
test_count="${P1_TEST_COUNT#*$'\n'"$test_file":}"
|
|
309
|
+
test_count="${test_count%%$'\n'*}"
|
|
310
|
+
case "$test_count" in ''|*[!0-9]*) test_count=0 ;; esac
|
|
311
|
+
if [ "$test_count" -gt 0 ]; then
|
|
312
|
+
report "CRITICAL" "$rel_path" "1" "Test file has $test_count test(s) but never imports source code -- tests only test inline mocks"
|
|
313
|
+
fi
|
|
314
|
+
fi
|
|
315
|
+
done < <(printf '%s\n' ${SET_TEST_ONLY[@]+"${SET_TEST_ONLY[@]}"})
|
|
316
|
+
|
|
317
|
+
# Pattern 2: Tautological assertions on literals
|
|
318
|
+
echo -e "${CYAN}Scanning for tautological assertions...${NC}"
|
|
319
|
+
# Each of these was one grep PER FILE inside a per-file loop. grep -H already
|
|
320
|
+
# emits `path:lineno:text` grouped by file in list order, so the stream IS the
|
|
321
|
+
# iteration and the outer loop is redundant. Patterns are byte-identical.
|
|
322
|
+
# Process substitution (not a pipe) keeps `report` in the current shell, so the
|
|
323
|
+
# HIGH counter still increments -- a pipe would send every count to a subshell
|
|
324
|
+
# and silently zero the gate.
|
|
325
|
+
while IFS=: read -r f lineno line; do
|
|
326
|
+
report "HIGH" "${f#$PROJECT_DIR/}" "$lineno" "Tautological assertion on literal string"
|
|
327
|
+
done < <(scan_lines "assert\.(ok|strictEqual|equal)\(['\"].*['\"]\.includes\(['\"]" ${SET_WITH_PY[@]+"${SET_WITH_PY[@]}"})
|
|
328
|
+
|
|
329
|
+
while IFS=: read -r f lineno line; do
|
|
330
|
+
report "HIGH" "${f#$PROJECT_DIR/}" "$lineno" "Tautological assertion: expect(literal).toBe(same literal)"
|
|
331
|
+
done < <(scan_lines "expect\(true\)\.toBe\(true\)|expect\(false\)\.toBe\(false\)|expect\([0-9]+\)\.toBe\([0-9]+\)" ${SET_WITH_PY[@]+"${SET_WITH_PY[@]}"})
|
|
332
|
+
|
|
333
|
+
while IFS=: read -r f lineno line; do
|
|
334
|
+
report "HIGH" "${f#$PROJECT_DIR/}" "$lineno" "Tautological assertion: assert.ok(true) always passes"
|
|
335
|
+
done < <(scan_lines "assert\.ok\((true|1)\)" ${SET_WITH_PY[@]+"${SET_WITH_PY[@]}"})
|
|
336
|
+
|
|
337
|
+
# Pattern 3: Conditional assertions (if guards that silently skip)
|
|
338
|
+
echo -e "${CYAN}Scanning for conditional assertions...${NC}"
|
|
339
|
+
# The if-guard pattern is kept BYTE-IDENTICAL, including the `\s` (a GNU/BSD
|
|
340
|
+
# grep -E extension, not POSIX). "Fixing" it to [[:space:]] would be a semantic
|
|
341
|
+
# change smuggled in as a cleanup.
|
|
342
|
+
#
|
|
343
|
+
# Only the DISCOVERY is streamed. The per-match window check keeps its original
|
|
344
|
+
# `sed -n` + `grep -q` body: it fires ~167 times total on this repo, which is
|
|
345
|
+
# noise next to the 2,495 greps removed elsewhere, and leaving it untouched
|
|
346
|
+
# means there is less behavior to re-prove.
|
|
347
|
+
# The window check (do lines ln+1..ln+3 contain assert./expect( ?) used to be a
|
|
348
|
+
# `sed -n` plus a `grep -q` PER MATCHED LINE. One awk now does both: it re-reads
|
|
349
|
+
# each matching file once and tests the same 3-line window with the same
|
|
350
|
+
# substring test. Window bounds and match semantics are unchanged; only the
|
|
351
|
+
# ~340 subprocesses are gone.
|
|
352
|
+
while IFS=: read -r f ln; do
|
|
353
|
+
report "MEDIUM" "${f#$PROJECT_DIR/}" "$ln" "Conditional assertion: expect/assert inside if-guard may silently pass"
|
|
354
|
+
done < <(
|
|
355
|
+
scan_lines "if\s*\(.*\)\s*\{?\s*$" ${SET_TEST_SPEC[@]+"${SET_TEST_SPEC[@]}"} \
|
|
356
|
+
| awk -F: '
|
|
357
|
+
# Stream of `path:lineno:text`. Collect the candidate line numbers per
|
|
358
|
+
# path, then read each path once and check its windows.
|
|
359
|
+
{ p = $1; hits[p] = hits[p] " " $2 }
|
|
360
|
+
END {
|
|
361
|
+
for (p in hits) {
|
|
362
|
+
nl = 0
|
|
363
|
+
delete L
|
|
364
|
+
while ((getline ln < p) > 0) L[++nl] = ln
|
|
365
|
+
close(p)
|
|
366
|
+
n = split(hits[p], cand, " ")
|
|
367
|
+
for (i = 1; i <= n; i++) {
|
|
368
|
+
c = cand[i] + 0
|
|
369
|
+
if (c == 0) continue
|
|
370
|
+
win = ""
|
|
371
|
+
for (o = 1; o <= 3; o++) if (c + o <= nl) win = win L[c + o] "\n"
|
|
372
|
+
if (index(win, "assert.") || index(win, "expect(")) print p ":" c
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
' | sort -t: -k1,1 -k2,2n
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Pattern 4: Empty test bodies
|
|
380
|
+
echo -e "${CYAN}Scanning for empty test bodies...${NC}"
|
|
381
|
+
# it('name', () => {}) or test('name', () => {})
|
|
382
|
+
while IFS=: read -r f lineno line; do
|
|
383
|
+
report "MEDIUM" "${f#$PROJECT_DIR/}" "$lineno" "Empty test body -- test does nothing"
|
|
384
|
+
done < <(scan_lines "(it|test)\(['\"].*['\"],\s*(\(\)|function\s*\(\))\s*\{?\s*\}?\s*\);" ${SET_TEST_SPEC[@]+"${SET_TEST_SPEC[@]}"})
|
|
385
|
+
|
|
386
|
+
# Pattern 5: Skipped tests
|
|
387
|
+
echo -e "${CYAN}Scanning for skipped tests...${NC}"
|
|
388
|
+
# The original capped this at `head -5` PER FILE. In a single stream the cap
|
|
389
|
+
# becomes a counter that resets when the path changes -- same 5-per-file limit.
|
|
390
|
+
# A counter rather than a pipe because `report` must stay in the current shell.
|
|
391
|
+
_p5_file=""
|
|
392
|
+
_p5_n=0
|
|
393
|
+
while IFS=: read -r f lineno line; do
|
|
394
|
+
if [ "$f" != "$_p5_file" ]; then _p5_file="$f"; _p5_n=0; fi
|
|
395
|
+
_p5_n=$((_p5_n + 1))
|
|
396
|
+
[ "$_p5_n" -le 5 ] || continue
|
|
397
|
+
report "LOW" "${f#$PROJECT_DIR/}" "$lineno" "Skipped test: $line"
|
|
398
|
+
done < <(scan_lines "(xit|xtest|xdescribe|\.skip)\(" ${SET_WITH_PY[@]+"${SET_WITH_PY[@]}"})
|
|
399
|
+
|
|
400
|
+
# Pattern 6: Internal vs External mock classification
|
|
401
|
+
# Internal mocks (mocking your own code) are problematic -- you're hiding bugs
|
|
402
|
+
# External mocks (mocking HTTP, DB, filesystem, APIs) are expected
|
|
403
|
+
echo -e "${CYAN}Scanning for internal mock ratio...${NC}"
|
|
404
|
+
|
|
405
|
+
# Mock patterns for external services (acceptable)
|
|
406
|
+
EXTERNAL_MOCK_PATTERN='(fetch|axios|http|request|database|db\.|redis|pg\.|mysql|mongo|s3|aws|gcp|azure|stripe|twilio|sendgrid|smtp|mailer|fs\.|readFile|writeFile|unlink|mkdir|createServer|listen|connect|socket)'
|
|
407
|
+
# Mock patterns for internal code (problematic if excessive)
|
|
408
|
+
INTERNAL_MOCK_PATTERN='(jest\.fn|sinon\.stub|sinon\.spy|vi\.fn|mock\(\)|spyOn|jest\.spyOn|stub\()'
|
|
409
|
+
|
|
410
|
+
# This ratio needs BOTH counts for the same file, so the two `grep -cH` streams
|
|
411
|
+
# are joined on path by one awk. The thresholds are unchanged; awk only decides
|
|
412
|
+
# WHICH files trip them, and bash still does the reporting so the severity
|
|
413
|
+
# counters keep incrementing in the current shell.
|
|
414
|
+
#
|
|
415
|
+
# `grep -c` emits a row for every file (zeros included), so the join is complete
|
|
416
|
+
# and a file missing from the internal stream simply never trips a threshold.
|
|
417
|
+
if [ "${#SET_TEST_SPEC[@]}" -gt 0 ]; then
|
|
418
|
+
while IFS=: read -r sev f total external; do
|
|
419
|
+
rel_path="${f#$PROJECT_DIR/}"
|
|
420
|
+
if [ "$sev" = "HIGH" ]; then
|
|
421
|
+
report "HIGH" "$rel_path" "1" "High internal mock ratio: $total mocks with 0 external service references -- likely mocking own code"
|
|
422
|
+
else
|
|
423
|
+
report "MEDIUM" "$rel_path" "1" "Elevated internal mock ratio: $total mocks, only $external external refs -- review mock targets"
|
|
424
|
+
fi
|
|
425
|
+
done < <(
|
|
426
|
+
{
|
|
427
|
+
printf '%s\0' "${SET_TEST_SPEC[@]}" | xargs -0 grep -cHE -- "$INTERNAL_MOCK_PATTERN" 2>/dev/null | sed 's/^/I:/' || true
|
|
428
|
+
printf '%s\0' "${SET_TEST_SPEC[@]}" | xargs -0 grep -cHE -- "$EXTERNAL_MOCK_PATTERN" 2>/dev/null | sed 's/^/E:/' || true
|
|
429
|
+
} | awk -F: '
|
|
430
|
+
# Rows arrive as I:<path>:<count> then E:<path>:<count>. Rebuild the path
|
|
431
|
+
# from fields 2..NF-1 so a path containing a colon still joins correctly.
|
|
432
|
+
{
|
|
433
|
+
tag = $1; n = $NF
|
|
434
|
+
path = $2
|
|
435
|
+
for (i = 3; i < NF; i++) path = path ":" $i
|
|
436
|
+
if (tag == "I") internal[path] = n; else external[path] = n
|
|
437
|
+
}
|
|
438
|
+
END {
|
|
439
|
+
for (p in internal) {
|
|
440
|
+
t = internal[p] + 0; e = external[p] + 0
|
|
441
|
+
# Thresholds copied verbatim from the original bash conditions.
|
|
442
|
+
if (t > 5 && e == 0) print "HIGH:" p ":" t ":" e
|
|
443
|
+
else if (t > 10 && e < 3) print "MEDIUM:" p ":" t ":" e
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
' | sort -t: -k2
|
|
447
|
+
)
|
|
448
|
+
fi
|
|
449
|
+
|
|
450
|
+
# Summary
|
|
451
|
+
echo ""
|
|
452
|
+
echo "=========================================="
|
|
453
|
+
TOTAL=$((CRITICAL + HIGH + MEDIUM + LOW))
|
|
454
|
+
echo "Results: $TOTAL finding(s)"
|
|
455
|
+
echo " CRITICAL: $CRITICAL"
|
|
456
|
+
echo " HIGH: $HIGH"
|
|
457
|
+
echo " MEDIUM: $MEDIUM"
|
|
458
|
+
echo " LOW: $LOW"
|
|
459
|
+
echo "=========================================="
|
|
460
|
+
|
|
461
|
+
if [ "$STRICT" = "--strict" ]; then
|
|
462
|
+
if [ $CRITICAL -gt 0 ] || [ $HIGH -gt 0 ]; then
|
|
463
|
+
echo ""
|
|
464
|
+
echo -e "${RED}GATE FAILED: $CRITICAL critical + $HIGH high findings${NC}"
|
|
465
|
+
exit 1
|
|
466
|
+
fi
|
|
467
|
+
fi
|
|
468
|
+
|
|
469
|
+
if [ $TOTAL -eq 0 ]; then
|
|
470
|
+
echo -e "${GREEN}All tests pass mock quality gate.${NC}"
|
|
471
|
+
fi
|
|
472
|
+
|
|
473
|
+
exit 0
|