loki-mode 7.49.0 → 7.51.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/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.49.0'
60
+ __version__ = '7.51.0'
package/mcp/lsp_proxy.py CHANGED
@@ -1304,6 +1304,180 @@ async def lsp_find_definition_by_name(symbol: str,
1304
1304
  # MAIN
1305
1305
  # ============================================================
1306
1306
 
1307
+ # ============================================================
1308
+ # DIAGNOSTICS WRITER (P1-5 quality gate)
1309
+ # ============================================================
1310
+ #
1311
+ # The LSP-diagnostics quality gate (loki-ts/src/runner/quality_gates.ts,
1312
+ # runLSPDiagnostics) READS <lokiDir>/quality/lsp-diagnostics.json but nothing
1313
+ # wrote it -- the gate was inert. This is the WRITER, invoked the same way on
1314
+ # both routes (Bun gate calls `python3 -m mcp.lsp_proxy --write-diagnostics`;
1315
+ # the bash route, when wired by the run.sh owner, calls the identical command),
1316
+ # so a single program produces byte-identical output for both. NO TS/bash
1317
+ # re-implementation of the aggregation -- that is the whole point of putting it
1318
+ # here.
1319
+ #
1320
+ # It enumerates the changed files itself (HEAD~1 -> --cached -> ls-files,
1321
+ # mirroring runStaticAnalysis's chain in quality_gates.ts so file selection
1322
+ # cannot diverge from the sibling static-analysis gate), queries each
1323
+ # supported file via the SAME in-process LSP client cache (_get_or_spawn_client,
1324
+ # one process for all files -- not python3-per-file), aggregates, and writes
1325
+ # the minimal deterministic shape the gate consumes.
1326
+ #
1327
+ # HONESTY (never fabricate a clean verdict from absence): when NO supported
1328
+ # language server is on PATH, or NO changed file maps to an available server,
1329
+ # the writer writes NO artifact and removes any stale one. The gate's existing
1330
+ # absence path then fires ("gate did not run") instead of a manufactured
1331
+ # "0 errors, 0 warnings" clean verdict. Likewise on any unrecoverable error
1332
+ # the writer leaves no artifact.
1333
+ #
1334
+ # DETERMINISM: the artifact carries ONLY the fields the gate reads
1335
+ # (count_errors, count_warnings, diagnostics[].severity/.message/.file).
1336
+ # Non-deterministic proxy fields (elapsed_ms, ranges, source) are dropped, and
1337
+ # diagnostics are sorted stably (file, severity, message) so the same inputs
1338
+ # always serialize byte-identically across runs and routes.
1339
+
1340
+ _DIAG_ARTIFACT_REL = os.path.join('quality', 'lsp-diagnostics.json')
1341
+
1342
+
1343
+ def _writer_loki_dir() -> str:
1344
+ """Resolve the .loki dir the artifact is written under. Honors LOKI_DIR
1345
+ (matching loki-ts/src/util/paths.ts lokiDir()), else <cwd>/.loki."""
1346
+ env = os.environ.get('LOKI_DIR')
1347
+ if env:
1348
+ return env
1349
+ return os.path.join(os.getcwd(), '.loki')
1350
+
1351
+
1352
+ def _writer_changed_files(root: str) -> List[str]:
1353
+ """Return changed files relative to `root`, mirroring the HEAD~1 ->
1354
+ --cached -> ls-files chain in runStaticAnalysis (quality_gates.ts). An
1355
+ empty-but-successful `git diff HEAD~1 HEAD` is honored as "no changes
1356
+ this iteration" and does NOT fall through to ls-files (parity with the
1357
+ tryGit null-vs-empty distinction in the TS gate)."""
1358
+ def _try_git(git_args: List[str]) -> Optional[str]:
1359
+ try:
1360
+ proc = subprocess.run(
1361
+ ['git', '-C', root, *git_args],
1362
+ capture_output=True, text=True, timeout=30,
1363
+ )
1364
+ except (OSError, subprocess.SubprocessError):
1365
+ return None
1366
+ if proc.returncode != 0:
1367
+ return None
1368
+ return proc.stdout
1369
+
1370
+ raw: str
1371
+ head_tilde = _try_git(['diff', '--name-only', 'HEAD~1', 'HEAD'])
1372
+ if head_tilde is not None:
1373
+ raw = head_tilde
1374
+ else:
1375
+ cached = _try_git(['diff', '--name-only', '--cached'])
1376
+ if cached is not None and cached.strip():
1377
+ raw = cached
1378
+ else:
1379
+ ls_files = _try_git(['ls-files'])
1380
+ raw = ls_files if (ls_files is not None and ls_files.strip()) else ''
1381
+ return [line.strip() for line in raw.splitlines() if line.strip()]
1382
+
1383
+
1384
+ def write_diagnostics_artifact(root: Optional[str] = None,
1385
+ loki_dir: Optional[str] = None) -> Dict[str, Any]:
1386
+ """Enumerate changed files, query LSP diagnostics per supported file via
1387
+ the shared in-process client cache, aggregate, and atomically write the
1388
+ gate artifact. Returns a status dict (also printed by --write-diagnostics).
1389
+
1390
+ Writes NO artifact (and removes a stale one) when there is nothing real to
1391
+ measure -- no detected server, or no changed file maps to a detected
1392
+ server -- so the gate's absence path fires honestly instead of a fabricated
1393
+ clean verdict."""
1394
+ root = root or os.getcwd()
1395
+ loki_dir = loki_dir or _writer_loki_dir()
1396
+ artifact_path = os.path.join(loki_dir, _DIAG_ARTIFACT_REL)
1397
+
1398
+ def _remove_stale() -> None:
1399
+ try:
1400
+ if os.path.isfile(artifact_path):
1401
+ os.remove(artifact_path)
1402
+ except OSError:
1403
+ pass
1404
+
1405
+ detected = _detect_lsps()
1406
+ if not detected:
1407
+ _remove_stale()
1408
+ return {'measured': False, 'reason': 'no-language-server-on-path',
1409
+ 'wrote_artifact': False}
1410
+
1411
+ changed_rel = _writer_changed_files(root)
1412
+ # Keep only files whose language has a detected server AND that exist on
1413
+ # disk (skip deleted/renamed diff entries).
1414
+ targets: List[str] = []
1415
+ for rel in changed_rel:
1416
+ abs_path = rel if os.path.isabs(rel) else os.path.join(root, rel)
1417
+ if not os.path.isfile(abs_path):
1418
+ continue
1419
+ lang = _suffix_to_language(abs_path)
1420
+ if lang is None or lang not in detected:
1421
+ continue
1422
+ targets.append(abs_path)
1423
+
1424
+ if not targets:
1425
+ _remove_stale()
1426
+ return {'measured': False, 'reason': 'no-changed-file-with-detected-server',
1427
+ 'wrote_artifact': False,
1428
+ 'detected': sorted(detected.keys())}
1429
+
1430
+ all_diags: List[Dict[str, Any]] = []
1431
+ queried = 0
1432
+ for abs_path in targets:
1433
+ try:
1434
+ raw = _lsp_get_diagnostics_blocking(abs_path)
1435
+ parsed = json.loads(raw)
1436
+ except (OSError, ValueError):
1437
+ continue
1438
+ if 'error' in parsed:
1439
+ # Server present in detection but failed for this file (spawn
1440
+ # failure, unsupported, etc.). Skip it -- do NOT count it clean.
1441
+ continue
1442
+ queried += 1
1443
+ for d in parsed.get('diagnostics', []) or []:
1444
+ sev = d.get('severity')
1445
+ if not isinstance(sev, int):
1446
+ continue
1447
+ all_diags.append({
1448
+ 'file': abs_path,
1449
+ 'severity': sev,
1450
+ 'message': str(d.get('message', '')),
1451
+ })
1452
+
1453
+ if queried == 0:
1454
+ # Every detected-server file failed to produce a usable result. We
1455
+ # measured nothing real -- do not fabricate a clean artifact.
1456
+ _remove_stale()
1457
+ return {'measured': False, 'reason': 'no-file-yielded-diagnostics',
1458
+ 'wrote_artifact': False, 'detected': sorted(detected.keys())}
1459
+
1460
+ # Stable sort so the same inputs serialize identically across runs/routes.
1461
+ all_diags.sort(key=lambda d: (d['file'], d['severity'], d['message']))
1462
+ count_errors = sum(1 for d in all_diags if d['severity'] == 1)
1463
+ count_warnings = sum(1 for d in all_diags if d['severity'] == 2)
1464
+ artifact = {
1465
+ 'count_errors': count_errors,
1466
+ 'count_warnings': count_warnings,
1467
+ 'diagnostics': all_diags,
1468
+ }
1469
+
1470
+ os.makedirs(os.path.dirname(artifact_path), exist_ok=True)
1471
+ body = json.dumps(artifact, indent=2, sort_keys=True) + '\n'
1472
+ tmp_path = f'{artifact_path}.tmp.{os.getpid()}'
1473
+ with open(tmp_path, 'w', encoding='utf-8') as fh:
1474
+ fh.write(body)
1475
+ os.replace(tmp_path, artifact_path)
1476
+ return {'measured': True, 'wrote_artifact': True, 'path': artifact_path,
1477
+ 'files_queried': queried, 'count_errors': count_errors,
1478
+ 'count_warnings': count_warnings}
1479
+
1480
+
1307
1481
  def main() -> None:
1308
1482
  import argparse
1309
1483
  parser = argparse.ArgumentParser(
@@ -1317,7 +1491,36 @@ def main() -> None:
1317
1491
  '--port', type=int, default=8422,
1318
1492
  help='Port for HTTP transport (default: 8422).',
1319
1493
  )
1494
+ parser.add_argument(
1495
+ '--write-diagnostics', action='store_true',
1496
+ help='One-shot: enumerate changed files, query LSP diagnostics for '
1497
+ 'each supported file, and write the quality-gate artifact at '
1498
+ '<LOKI_DIR>/quality/lsp-diagnostics.json. Writes nothing when no '
1499
+ 'language server is available (the gate then reports "did not '
1500
+ 'run"). Used by the LSP-diagnostics quality gate on both routes.',
1501
+ )
1502
+ parser.add_argument(
1503
+ '--root', default=None,
1504
+ help='Project root to enumerate changed files in (default: cwd). The '
1505
+ 'caller MUST pass the TARGET project dir here, not the loki '
1506
+ 'install dir -- the install dir is only the import path for '
1507
+ '`-m mcp.lsp_proxy`.',
1508
+ )
1320
1509
  args = parser.parse_args()
1510
+
1511
+ if args.write_diagnostics:
1512
+ # One-shot writer mode -- no MCP server, no event loop. Always cleans
1513
+ # up any spawned LSP clients before exit. `--root` is the TARGET
1514
+ # project (where the diff lives); it is independent of the process cwd
1515
+ # (which the caller may set to the install dir so `-m mcp.lsp_proxy`
1516
+ # imports).
1517
+ try:
1518
+ status = write_diagnostics_artifact(root=args.root)
1519
+ finally:
1520
+ _cleanup_all_clients()
1521
+ print(json.dumps(status))
1522
+ return
1523
+
1321
1524
  # SIGTERM handler so docker stop / supervisord stop triggers cleanup
1322
1525
  # symmetrically to atexit. Re-raises via default handler so the
1323
1526
  # process actually exits.
@@ -373,5 +373,174 @@ class LSPClientShutdownTests(unittest.TestCase):
373
373
  fake_proc.kill.assert_called()
374
374
 
375
375
 
376
+ class DiagnosticsWriterTests(unittest.TestCase):
377
+ """P1-5: the diagnostics WRITER that feeds the LSP-diagnostics quality
378
+ gate (loki-ts/src/runner/quality_gates.ts runLSPDiagnostics). Proves
379
+ non-vacuity (real severity-1 diagnostics -> blocking artifact) at the
380
+ recording/aggregation layer, and the no-false-fire honesty paths (no
381
+ server / no measurable file -> NO artifact, so the gate's absence path
382
+ fires instead of a fabricated clean verdict)."""
383
+
384
+ def setUp(self):
385
+ self.lp = _import_lsp_proxy()
386
+ self.lp._reset_detection_cache()
387
+
388
+ def _make_repo(self, files):
389
+ """Create a tmp dir with the given {relpath: contents} and return it."""
390
+ d = tempfile.mkdtemp(prefix='loki-lsp-writer-')
391
+ for rel, body in files.items():
392
+ p = os.path.join(d, rel)
393
+ os.makedirs(os.path.dirname(p), exist_ok=True)
394
+ with open(p, 'w', encoding='utf-8') as fh:
395
+ fh.write(body)
396
+ return d
397
+
398
+ def test_no_server_writes_no_artifact(self):
399
+ """No detected language server -> measured:false, no artifact. The
400
+ gate must then report 'did not run', never a clean verdict."""
401
+ root = self._make_repo({'a.ts': 'const x = 1;\n'})
402
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
403
+ with mock.patch.object(self.lp, '_detect_lsps', return_value={}):
404
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
405
+ self.assertFalse(status['measured'])
406
+ self.assertFalse(status['wrote_artifact'])
407
+ self.assertFalse(os.path.isfile(
408
+ os.path.join(loki, 'quality', 'lsp-diagnostics.json')))
409
+
410
+ def test_server_present_but_no_matching_changed_file(self):
411
+ """Server detected for rust, but the changed file is .ts -> nothing
412
+ real to measure -> no artifact."""
413
+ root = self._make_repo({'a.ts': 'const x = 1;\n'})
414
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
415
+ with mock.patch.object(self.lp, '_detect_lsps',
416
+ return_value={'rust': '/usr/bin/rust-analyzer'}), \
417
+ mock.patch.object(self.lp, '_writer_changed_files',
418
+ return_value=['a.ts']):
419
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
420
+ self.assertFalse(status['measured'])
421
+ self.assertEqual(status['reason'], 'no-changed-file-with-detected-server')
422
+ self.assertFalse(os.path.isfile(
423
+ os.path.join(loki, 'quality', 'lsp-diagnostics.json')))
424
+
425
+ def test_real_error_recorded_and_blocks(self):
426
+ """NON-VACUITY: a severity-1 diagnostic from the per-file LSP query is
427
+ recorded into the artifact with count_errors>0 -- the exact shape the
428
+ gate blocks on. Diagnostics source is mocked at the proxy boundary so
429
+ this exercises the writer's enumeration + aggregation + serialization
430
+ WITHOUT fabricating a verdict (the gate still independently reads the
431
+ file)."""
432
+ root = self._make_repo({'src/main.py': 'x: int = "nope"\n'})
433
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
434
+ fake = json.dumps({
435
+ 'diagnostics': [
436
+ {'severity': 1, 'message': 'Type error', 'range': {}, 'source': 'x'},
437
+ {'severity': 2, 'message': 'Unused', 'range': {}},
438
+ {'severity': 3, 'message': 'Info note'},
439
+ ],
440
+ 'count_errors': 1, 'count_warnings': 1,
441
+ 'language': 'python', 'elapsed_ms': 123.4,
442
+ })
443
+ with mock.patch.object(self.lp, '_detect_lsps',
444
+ return_value={'python': '/usr/bin/pyright'}), \
445
+ mock.patch.object(self.lp, '_writer_changed_files',
446
+ return_value=['src/main.py']), \
447
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
448
+ return_value=fake):
449
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
450
+ self.assertTrue(status['measured'])
451
+ self.assertTrue(status['wrote_artifact'])
452
+ self.assertEqual(status['count_errors'], 1)
453
+ path = os.path.join(loki, 'quality', 'lsp-diagnostics.json')
454
+ self.assertTrue(os.path.isfile(path))
455
+ with open(path, encoding='utf-8') as fh:
456
+ artifact = json.load(fh)
457
+ self.assertEqual(artifact['count_errors'], 1)
458
+ self.assertEqual(artifact['count_warnings'], 1)
459
+ # Minimal deterministic shape: only severity/message/file survive;
460
+ # elapsed_ms / range / source are stripped.
461
+ self.assertEqual(len(artifact['diagnostics']), 3)
462
+ for d in artifact['diagnostics']:
463
+ self.assertEqual(set(d.keys()), {'file', 'severity', 'message'})
464
+ self.assertNotIn('elapsed_ms', artifact)
465
+
466
+ def test_per_file_lsp_error_does_not_count_clean(self):
467
+ """A detected server that returns {'error':...} for the only changed
468
+ file means we measured nothing -> no artifact (NOT a fabricated
469
+ clean verdict)."""
470
+ root = self._make_repo({'src/main.py': 'x = 1\n'})
471
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
472
+ with mock.patch.object(self.lp, '_detect_lsps',
473
+ return_value={'python': '/usr/bin/pyright'}), \
474
+ mock.patch.object(self.lp, '_writer_changed_files',
475
+ return_value=['src/main.py']), \
476
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
477
+ return_value=json.dumps({'error': 'spawn failed'})):
478
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
479
+ self.assertFalse(status['measured'])
480
+ self.assertEqual(status['reason'], 'no-file-yielded-diagnostics')
481
+ self.assertFalse(os.path.isfile(
482
+ os.path.join(loki, 'quality', 'lsp-diagnostics.json')))
483
+
484
+ def test_clean_file_writes_zero_artifact(self):
485
+ """A measured file with no diagnostics writes a real 0/0 artifact --
486
+ this is a MEASURED clean result (we queried a live server), distinct
487
+ from the absence path."""
488
+ root = self._make_repo({'src/main.py': 'x = 1\n'})
489
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
490
+ with mock.patch.object(self.lp, '_detect_lsps',
491
+ return_value={'python': '/usr/bin/pyright'}), \
492
+ mock.patch.object(self.lp, '_writer_changed_files',
493
+ return_value=['src/main.py']), \
494
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
495
+ return_value=json.dumps({
496
+ 'diagnostics': [], 'count_errors': 0,
497
+ 'count_warnings': 0})):
498
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
499
+ self.assertTrue(status['measured'])
500
+ path = os.path.join(loki, 'quality', 'lsp-diagnostics.json')
501
+ with open(path, encoding='utf-8') as fh:
502
+ artifact = json.load(fh)
503
+ self.assertEqual(artifact['count_errors'], 0)
504
+ self.assertEqual(artifact['count_warnings'], 0)
505
+ self.assertEqual(artifact['diagnostics'], [])
506
+
507
+ def test_stale_artifact_removed_when_unmeasured(self):
508
+ """A previously-written artifact must be removed when a later run
509
+ measures nothing, so last iteration's errors cannot block forever."""
510
+ root = self._make_repo({'a.ts': 'const x = 1;\n'})
511
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
512
+ qdir = os.path.join(loki, 'quality')
513
+ os.makedirs(qdir, exist_ok=True)
514
+ stale = os.path.join(qdir, 'lsp-diagnostics.json')
515
+ with open(stale, 'w', encoding='utf-8') as fh:
516
+ fh.write('{"count_errors": 5, "count_warnings": 0, "diagnostics": []}')
517
+ with mock.patch.object(self.lp, '_detect_lsps', return_value={}):
518
+ self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
519
+ self.assertFalse(os.path.isfile(stale))
520
+
521
+ def test_deterministic_serialization(self):
522
+ """Same inputs -> byte-identical artifact (route + run parity)."""
523
+ root = self._make_repo({'src/main.py': 'x = 1\n'})
524
+ loki1 = tempfile.mkdtemp(prefix='loki-lsp-out-')
525
+ loki2 = tempfile.mkdtemp(prefix='loki-lsp-out-')
526
+ fake = json.dumps({'diagnostics': [
527
+ {'severity': 2, 'message': 'b'},
528
+ {'severity': 1, 'message': 'a'},
529
+ ]})
530
+ for lk in (loki1, loki2):
531
+ with mock.patch.object(self.lp, '_detect_lsps',
532
+ return_value={'python': '/usr/bin/pyright'}), \
533
+ mock.patch.object(self.lp, '_writer_changed_files',
534
+ return_value=['src/main.py']), \
535
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
536
+ return_value=fake):
537
+ self.lp.write_diagnostics_artifact(root=root, loki_dir=lk)
538
+ with open(os.path.join(loki1, 'quality', 'lsp-diagnostics.json'), 'rb') as fh:
539
+ b1 = fh.read()
540
+ with open(os.path.join(loki2, 'quality', 'lsp-diagnostics.json'), 'rb') as fh:
541
+ b2 = fh.read()
542
+ self.assertEqual(b1, b2)
543
+
544
+
376
545
  if __name__ == '__main__':
377
546
  unittest.main()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.49.0",
4
+ "version": "7.51.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.49.0",
5
+ "version": "7.51.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -0,0 +1,109 @@
1
+ # Spec-Independent Invariant Checks (P1-4)
2
+
3
+ Deterministic invariant assertions over the produced source code that hold
4
+ regardless of what the spec says. They catch the "spec was silent and the model
5
+ guessed wrong" failure mode: code that ships a hardcoded secret or logs PII is
6
+ wrong no matter what the PRD asked for.
7
+
8
+ Implementation: `tests/detect-invariant-violations.sh`
9
+ Tests: `tests/test-invariant-detector.sh`
10
+
11
+ ## What this is (and is not)
12
+
13
+ This is NOT a property-based test generator. The "Kiro Pattern" documented in
14
+ `skills/testing.md` (fast-check / hypothesis) generates randomized property
15
+ tests; that is a separate, larger feature and remains unimplemented. This
16
+ detector instead makes a small set of SOLID deterministic invariant ASSERTIONS
17
+ over the produced code. The design choice is explicit: a small set of solid
18
+ deterministic checks beats a large set of flaky ones.
19
+
20
+ ## Checks
21
+
22
+ ### Deterministic (blocking under `--strict`)
23
+
24
+ | # | Invariant | Severity | How |
25
+ |---|-----------|----------|-----|
26
+ | 1 | No committed secrets in source/logs | CRITICAL | Known credential prefixes: AWS access keys (`AKIA`/`ASIA`), PEM private-key blocks, GitHub tokens (`ghp_`/`gho_`/`ghs_`/`github_pat_`), Slack (`xox[baprs]-`), Google (`AIza`), Anthropic (`sk-ant-`), Stripe (`sk_live_`/`rk_live_`). Near-zero false positives. |
27
+ | 2 | No PII (email) in logs | HIGH | An email-shaped string literal passed to a log/print call (`console.*`, `logger.*`, `print(`, `fmt.Print*`, `echo`, `System.out.print`, `log.*`). |
28
+
29
+ ### Advisory (never blocks)
30
+
31
+ | # | Invariant | Severity | How |
32
+ |---|-----------|----------|-----|
33
+ | 3 | Generic secret-like assignment | MEDIUM | A variable named secret/token/password/apikey assigned a long opaque literal (and not env-var indirection). FP-prone, so advisory only. |
34
+ | 4 | Logged email-bearing variable | LOW | A `.email` / `userEmail` style variable referenced inside a log/print call. Cannot prove PII statically. |
35
+
36
+ ### Deferred (honestly NOT implemented)
37
+
38
+ These two requested categories are deferred because a static grep cannot do them
39
+ deterministically without becoming flaky. They are documented here, not faked:
40
+
41
+ - **No unhandled-error path on the happy route.** A grep cannot do control-flow
42
+ analysis; a "no try/catch near await" heuristic is noise. This belongs to a
43
+ real analysis pass (LSP diagnostics / typed exhaustiveness checking), tracked
44
+ separately as the LSP-in-verification work.
45
+ - **Idempotency / round-trip invariants.** Not statically detectable in any
46
+ deterministic way worth shipping. It requires executing generated tests (the
47
+ larger fast-check / metamorphic-testing feature). Deferred, not faked.
48
+
49
+ ## False-positive avoidance
50
+
51
+ Generated code legitimately contains placeholders. The detector skips them:
52
+
53
+ - Placeholder allowlist on the matched line: `EXAMPLE`, `example.com`, `your-`,
54
+ `xxxx`, `placeholder`, `changeme`, `REPLACE`, `<...>`, `dummy`, `sample`,
55
+ `redact`, `sk-test-`, `fake`, `FIXME`, `TODO`, `****`. This covers AWS's own
56
+ documented `AKIAIOSFODNN7EXAMPLE` and `your-api-key-here`.
57
+ - Illustration files skipped for secret checks: `*.md`, `*.example`, `*.sample`,
58
+ `*.template`, `*.dist` (they routinely show fake credentials on purpose).
59
+ - Env-var indirection (`process.env`, `os.environ`, `getenv`, `ENV[`) is not a
60
+ hardcoded literal and is skipped for the generic check.
61
+
62
+ ## Scan surface
63
+
64
+ Source files only. Extensions: ts, tsx, js, jsx, py, go, rb, java, rs, php, sh,
65
+ env, yml, yaml, json, plus `*.log`. Excludes `node_modules`, `.git`, `dist`,
66
+ `build`, `vendor`, `coverage`, and Loki's own `.loki/` telemetry.
67
+
68
+ Test files are OUT OF SCOPE for all checks (consistent with the "source/logs"
69
+ framing of this invariant). The exclusion covers every common ecosystem's test
70
+ convention, not just the JS glob: `*.test.*` / `*.spec.*`, `test_*.py` /
71
+ `*_test.py`, `test-*.sh`, `*_test.go`, and anything under an anchored
72
+ `tests/` / `__tests__/` / `spec/` directory. Security/redaction test fixtures
73
+ routinely embed realistic fake credentials on purpose, so scanning them would be
74
+ pure noise. Comprehensive secret scanning of generated TESTS (and pre-write
75
+ scanning) is a separate, larger feature tracked as P3-4 (#634), not this gate.
76
+
77
+ ## Usage
78
+
79
+ ```bash
80
+ # Advisory run (prints findings, exits 0)
81
+ tests/detect-invariant-violations.sh
82
+
83
+ # CI / gate run (exits 1 iff CRITICAL or HIGH)
84
+ tests/detect-invariant-violations.sh --strict
85
+
86
+ # Scan a target project (the gate wrapper sets this)
87
+ LOKI_SCAN_DIR=/path/to/target tests/detect-invariant-violations.sh --strict
88
+ ```
89
+
90
+ Exit-code contract mirrors `tests/detect-mock-problems.sh`: `--strict` exits 1
91
+ iff CRITICAL or HIGH findings exist; MEDIUM/LOW never block. Every HIGH (and
92
+ CRITICAL) prints a `[HIGH]` / `[CRITICAL]` token on stdout, so a wrapper can grep
93
+ as an alternative to relying on the exit code.
94
+
95
+ ## Wiring as a gate
96
+
97
+ The detector is NOT wired into `autonomy/run.sh` yet. To wire it, add an
98
+ `enforce_invariant_integrity()` wrapper next to `enforce_mock_integrity()`
99
+ (`autonomy/run.sh:7932`) and call it where `enforce_mock_integrity` is called
100
+ (`autonomy/run.sh:14676`). The full wrapper is documented in the header of
101
+ `tests/detect-invariant-violations.sh`. It:
102
+
103
+ - honors `LOKI_SCAN_DIR=TARGET_DIR` (the detector scans the target, not loki-mode)
104
+ - treats detector-not-found and timeout (exit 124) as inconclusive (does not block)
105
+ - persists findings to `${TARGET_DIR}/.loki/quality/invariant-findings.txt`
106
+ - opts out with `LOKI_GATE_INVARIANT=false`
107
+
108
+ After wiring, add a gate row to `skills/quality-gates.md` and cross-reference
109
+ this check from the Kiro Pattern section of `skills/testing.md`.