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/README.md +2 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/prd-analyzer.py +215 -1
- package/autonomy/prd-checklist.sh +315 -0
- package/autonomy/run.sh +350 -3
- package/autonomy/spec-interrogation.sh +224 -4
- package/autonomy/spec.sh +25 -16
- package/autonomy/verify.sh +108 -26
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +202 -21
- package/docs/INSTALLATION.md +2 -2
- package/docs/siem-integration.md +102 -0
- package/loki-ts/dist/loki.js +231 -230
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +203 -0
- package/mcp/tests/test_lsp_proxy.py +169 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/references/invariant-checks.md +109 -0
- package/src/audit/crosslink.js +413 -0
- package/src/audit/index.js +32 -0
- package/src/observability/siem-export.js +424 -0
- package/src/policies/cost.js +270 -1
package/mcp/__init__.py
CHANGED
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.
|
|
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.
|
|
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`.
|