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.
@@ -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