code-auditor-mcp 3.4.13 → 3.4.15

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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +96 -0
  3. package/dist/analyzers/applicability.d.ts +41 -0
  4. package/dist/analyzers/applicability.d.ts.map +1 -0
  5. package/dist/analyzers/applicability.js +96 -0
  6. package/dist/analyzers/applicability.js.map +1 -0
  7. package/dist/analyzers/ruleRegistry.js +11 -11
  8. package/dist/analyzers/ruleRegistry.js.map +1 -1
  9. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +8 -8
  10. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
  11. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +2 -2
  12. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
  13. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +2 -2
  14. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
  15. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
  16. package/dist/analyzers/universal/UniversalStylesAnalyzer.js +9 -1
  17. package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
  18. package/dist/analyzers/universal/schema/migrations.d.ts +11 -0
  19. package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -1
  20. package/dist/analyzers/universal/schema/migrations.js +127 -4
  21. package/dist/analyzers/universal/schema/migrations.js.map +1 -1
  22. package/dist/auditRunner.d.ts.map +1 -1
  23. package/dist/auditRunner.js +62 -23
  24. package/dist/auditRunner.js.map +1 -1
  25. package/dist/cli.js +393 -1
  26. package/dist/cli.js.map +1 -1
  27. package/dist/codeIndexDB.d.ts +40 -1
  28. package/dist/codeIndexDB.d.ts.map +1 -1
  29. package/dist/codeIndexDB.js +222 -50
  30. package/dist/codeIndexDB.js.map +1 -1
  31. package/dist/config/defaults.js +2 -2
  32. package/dist/config/defaults.js.map +1 -1
  33. package/dist/config/pathProfiles.d.ts +7 -0
  34. package/dist/config/pathProfiles.d.ts.map +1 -1
  35. package/dist/config/pathProfiles.js +7 -1
  36. package/dist/config/pathProfiles.js.map +1 -1
  37. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
  38. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1 -1
  39. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
  40. package/dist/ledger.d.ts +163 -2
  41. package/dist/ledger.d.ts.map +1 -1
  42. package/dist/ledger.js +528 -7
  43. package/dist/ledger.js.map +1 -1
  44. package/dist/mcp.js +3 -3
  45. package/dist/mcp.js.map +1 -1
  46. package/dist/mcpAuditJobs.d.ts +24 -2
  47. package/dist/mcpAuditJobs.d.ts.map +1 -1
  48. package/dist/mcpAuditJobs.js +364 -58
  49. package/dist/mcpAuditJobs.js.map +1 -1
  50. package/dist/pipeline.d.ts +9 -2
  51. package/dist/pipeline.d.ts.map +1 -1
  52. package/dist/pipeline.js +165 -18
  53. package/dist/pipeline.js.map +1 -1
  54. package/dist/pipelineAdapters.d.ts.map +1 -1
  55. package/dist/pipelineAdapters.js +45 -10
  56. package/dist/pipelineAdapters.js.map +1 -1
  57. package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
  58. package/dist/reporting/sarifReportGenerator.js +6 -1
  59. package/dist/reporting/sarifReportGenerator.js.map +1 -1
  60. package/dist/ruleAliases.d.ts.map +1 -1
  61. package/dist/ruleAliases.js +23 -0
  62. package/dist/ruleAliases.js.map +1 -1
  63. package/dist/scripts/benchmarkWorkers.js +1 -1
  64. package/dist/scripts/benchmarkWorkers.js.map +1 -1
  65. package/dist/services/auditJobService.d.ts +22 -5
  66. package/dist/services/auditJobService.d.ts.map +1 -1
  67. package/dist/services/auditJobService.js +67 -26
  68. package/dist/services/auditJobService.js.map +1 -1
  69. package/dist/services/fileAccounting.d.ts +83 -0
  70. package/dist/services/fileAccounting.d.ts.map +1 -0
  71. package/dist/services/fileAccounting.js +0 -0
  72. package/dist/services/fileAccounting.js.map +1 -0
  73. package/dist/styles/styleExtractor.d.ts +2 -2
  74. package/dist/styles/styleExtractor.d.ts.map +1 -1
  75. package/dist/styles/styleExtractor.js +83 -11
  76. package/dist/styles/styleExtractor.js.map +1 -1
  77. package/dist/styles/styleIndexer.d.ts +14 -0
  78. package/dist/styles/styleIndexer.d.ts.map +1 -1
  79. package/dist/styles/styleIndexer.js +117 -16
  80. package/dist/styles/styleIndexer.js.map +1 -1
  81. package/dist/styles/tailwindProbe.d.ts +31 -0
  82. package/dist/styles/tailwindProbe.d.ts.map +1 -1
  83. package/dist/styles/tailwindProbe.js +145 -4
  84. package/dist/styles/tailwindProbe.js.map +1 -1
  85. package/dist/styles/tailwindUtilityExpander.d.ts +3 -0
  86. package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
  87. package/dist/styles/tailwindUtilityExpander.js +35 -1
  88. package/dist/styles/tailwindUtilityExpander.js.map +1 -1
  89. package/dist/styles/types.d.ts +11 -0
  90. package/dist/styles/types.d.ts.map +1 -1
  91. package/dist/types.d.ts +130 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js.map +1 -1
  94. package/dist/utils/fileDiscovery.d.ts +71 -0
  95. package/dist/utils/fileDiscovery.d.ts.map +1 -1
  96. package/dist/utils/fileDiscovery.js +201 -25
  97. package/dist/utils/fileDiscovery.js.map +1 -1
  98. package/dist/workers/auditJobRunner.d.ts +2 -0
  99. package/dist/workers/auditJobRunner.d.ts.map +1 -0
  100. package/dist/workers/auditJobRunner.js +37 -0
  101. package/dist/workers/auditJobRunner.js.map +1 -0
  102. package/dist/workers/auditWorker.js +26 -0
  103. package/dist/workers/auditWorker.js.map +1 -1
  104. package/package.json +2 -2
  105. package/plugin/.claude-plugin/plugin.json +1 -1
@@ -2,20 +2,45 @@ import path from 'node:path';
2
2
  import { promises as fs } from 'node:fs';
3
3
  import { fork } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { cpus } from 'node:os';
5
+ import { cpus, hostname } from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { CodeIndexDB } from './codeIndexDB.js';
8
- import { syncFileIndex } from './codeIndexService.js';
9
8
  import { CodeMapGenerator } from './services/CodeMapGenerator.js';
10
9
  import { analyzeDocumentation } from './analyzers/documentationAnalyzer.js';
11
10
  import { assertAuditPathExists, ContextualError } from './mcpToolErrors.js';
12
11
  import { createAuditJob, getAuditJob, patchAuditJob, setAuditJobProgress } from './services/auditJobService.js';
12
+ import { detectRunInput, evaluateStaleRunning, hashFileSet, markRunsFailed, patchLedgerRun, writeAuditToLedger, } from './ledger.js';
13
+ import { PACKAGE_VERSION } from './constants.js';
13
14
  import { mcpDebugStderr } from './mcpDiagnostics.js';
14
15
  import { findFiles } from './utils/fileDiscovery.js';
15
- import { makeVisitorStatus, getFilesProcessed } from './pipeline.js';
16
+ import { makeVisitorStatus, getFilesProcessed, violationMatchesRule } from './pipeline.js';
17
+ import { RULE_REGISTRY } from './analyzers/ruleRegistry.js';
16
18
  import chalk from 'chalk';
17
19
  const SOURCE_FOLDERS = ['app', 'src'];
18
- const GLOBAL_ONLY_ANALYZERS = new Set(['dry', 'data-access', 'schema']);
20
+ // Cross-file reducers run once over the FULL scope, never inside partition
21
+ // shards. A shard sees only its partition's files and (worse) reads shared
22
+ // index tables that other shards are still writing, so any reducer that reads
23
+ // accumulated state must be global. The set is every reducer/derived-reducer
24
+ // in the pipeline stage model:
25
+ // dry — cross-file duplicate detection over the function index
26
+ // data-access — per-file, but kept global so its coverage/status is a single
27
+ // full-scope row (harmless to shard, but global is uniform)
28
+ // schema — schema_usage reducers (unknown-table, JSON validation)
29
+ // styles — reads style_declarations/tokens/class_usage written by the
30
+ // styles-css visitor + syncStyleIndex (same analyzer gate)
31
+ // conventions — reads function_calls/conventions via updateDependencyGraph +
32
+ // mineAllConventions (same analyzer gate)
33
+ // invariants — call-constraint/module-boundary need the full file list
34
+ // cross-domain— reads schema_usage/indexed_functions/graph_cache (stage 4)
35
+ const GLOBAL_ONLY_ANALYZERS = new Set([
36
+ 'dry',
37
+ 'data-access',
38
+ 'schema',
39
+ 'styles',
40
+ 'conventions',
41
+ 'invariants',
42
+ 'cross-domain',
43
+ ]);
19
44
  const RETRYABLE_ERROR_PATTERNS = [/timed out/i, /timeout/i, /econnreset/i, /eagain/i, /emfile/i];
20
45
  /** Hard cap so pathological configs cannot fork unbounded processes. */
21
46
  const MAX_AUDIT_WORKERS = 8;
@@ -31,6 +56,100 @@ function defaultJobTimeoutMs() {
31
56
  return DEFAULT_JOB_TIMEOUT_MS;
32
57
  return Math.min(n, ABSOLUTE_MAX_JOB_TIMEOUT_MS);
33
58
  }
59
+ // Spec 41 R5 — heartbeat-based concurrency lease. A plain `status='running'`
60
+ // count wedges the queue behind a crashed job's ghost row forever; a stale
61
+ // heartbeat is reclaimable instead.
62
+ const DEFAULT_JOB_LEASE_TTL_MS = 30 * 1000;
63
+ const DEFAULT_MAX_RUNNING_JOBS = 1;
64
+ function jobLeaseTtlMs() {
65
+ const raw = process.env.CODE_AUDITOR_JOB_LEASE_TTL_MS;
66
+ const n = Number(raw);
67
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_JOB_LEASE_TTL_MS;
68
+ }
69
+ function maxRunningJobs() {
70
+ const raw = process.env.CODE_AUDITOR_MAX_RUNNING_JOBS;
71
+ const n = Number(raw);
72
+ return Number.isFinite(n) && n >= 1 ? n : DEFAULT_MAX_RUNNING_JOBS;
73
+ }
74
+ /** True when a write hit better-sqlite3's busy timeout under lock contention. */
75
+ function isBusyError(e) {
76
+ if (!(e instanceof Error))
77
+ return false;
78
+ const code = e.code;
79
+ return code === 'SQLITE_BUSY' || /database is locked/i.test(e.message);
80
+ }
81
+ /**
82
+ * Claims the `running` slot for `jobId`. The reclaim-then-count-then-claim
83
+ * sequence runs under `BEGIN IMMEDIATE` because the lease is contested by
84
+ * separate forked processes (better-sqlite3 serializes writes per-connection,
85
+ * not per-DB) — without the write lock two children could both see zero
86
+ * `running` rows and both claim the slot. Stale `running` rows (killed runners)
87
+ * are reclaimed first, so a ghost never wedges the queue.
88
+ *
89
+ * The polling path is deliberately read-only: while a healthy runner holds the
90
+ * slot, a queued job evaluates the lease pool via `evaluateStaleRunning` (SELECT
91
+ * + `kill(0)`/`ps`, no write lock) and sleeps. It only grabs `BEGIN IMMEDIATE`
92
+ * when the pool shows a free slot or a reclaimable ghost. Grabbing the write
93
+ * lock every poll would contend with the running job's own write transactions
94
+ * (the long `syncFileIndex`/`updateDependencyGraph` phase) and crash it with
95
+ * `database is locked`.
96
+ */
97
+ async function acquireLease(db, projectRoot, jobId) {
98
+ const ttl = jobLeaseTtlMs();
99
+ const limit = maxRunningJobs();
100
+ const now = () => new Date().toISOString();
101
+ // The transaction only performs the *write* half of reclaim — `reclaimable`
102
+ // and `cutoff` are computed read-only outside the lock (see below), so the
103
+ // `ps` liveness check never runs while holding the write lock.
104
+ const claim = db.transaction((reclaimable, cutoff) => {
105
+ markRunsFailed(db, reclaimable, cutoff);
106
+ const row = db
107
+ .prepare('SELECT COUNT(*) AS cnt FROM findings_ledger_runs WHERE project_root = ? AND status = ?')
108
+ .get(projectRoot, 'running');
109
+ if (row.cnt < limit) {
110
+ // Record PID-based liveness at claim (Spec 41 Amendment B): the child's
111
+ // PID + process start time (defeats PID reuse) + hostname (foreign hosts
112
+ // fall back to the heartbeat). `process.uptime()` in the forked child is
113
+ // measured from the child's start, so `now - uptime` is its true start.
114
+ patchLedgerRun(db, jobId, {
115
+ status: 'running',
116
+ startedAt: now(),
117
+ heartbeatAt: now(),
118
+ runnerPid: process.pid,
119
+ runnerPidStartedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
120
+ runnerHost: hostname(),
121
+ });
122
+ return true;
123
+ }
124
+ return false;
125
+ });
126
+ for (;;) {
127
+ // Read-only evaluation — no write lock, so it never contends with the
128
+ // running job's write transactions (the `ps` spawn for a stale-heartbeat
129
+ // candidate happens here, outside the lock).
130
+ const evaluation = evaluateStaleRunning(db, projectRoot, ttl);
131
+ const mightClaim = evaluation.runningCount < limit || evaluation.reclaimable.length > 0;
132
+ if (mightClaim) {
133
+ try {
134
+ if (claim.immediate(evaluation.reclaimable, evaluation.cutoff))
135
+ return;
136
+ }
137
+ catch (e) {
138
+ // The lease is contested by separate forked processes. A running job can
139
+ // hold the write lock longer than the busy timeout (e.g. the long
140
+ // `writeAuditToLedger` completion transaction), surfacing as SQLITE_BUSY.
141
+ // That is contention, not failure — wait and retry rather than failing
142
+ // the queued job (which would cascade into failing the running job too).
143
+ if (isBusyError(e)) {
144
+ await new Promise((resolve) => setTimeout(resolve, Math.max(250, Math.floor(ttl / 4))));
145
+ continue;
146
+ }
147
+ throw e;
148
+ }
149
+ }
150
+ await new Promise((resolve) => setTimeout(resolve, Math.max(250, Math.floor(ttl / 4))));
151
+ }
152
+ }
34
153
  function resolveWorkerEntrypoint() {
35
154
  const current = fileURLToPath(import.meta.url);
36
155
  const ext = path.extname(current);
@@ -38,6 +157,14 @@ function resolveWorkerEntrypoint() {
38
157
  const filename = ext === '.ts' ? 'auditWorker.ts' : 'auditWorker.js';
39
158
  return path.join(dir, 'workers', filename);
40
159
  }
160
+ /** Entrypoint for the detached CLI runner (Spec 41 `--detach`). */
161
+ export function resolveJobRunnerEntrypoint() {
162
+ const current = fileURLToPath(import.meta.url);
163
+ const ext = path.extname(current);
164
+ const dir = path.dirname(current);
165
+ const filename = ext === '.ts' ? 'auditJobRunner.ts' : 'auditJobRunner.js';
166
+ return path.join(dir, 'workers', filename);
167
+ }
41
168
  function asSerializableConfig(options) {
42
169
  return {
43
170
  projectRoot: options.projectRoot || process.cwd(),
@@ -57,7 +184,7 @@ function asSerializableConfig(options) {
57
184
  function isRetryableShardError(error) {
58
185
  return RETRYABLE_ERROR_PATTERNS.some((p) => p.test(error));
59
186
  }
60
- async function runShardTasksWithWorkerPool(jobId, tasks, options) {
187
+ async function runShardTasksWithWorkerPool(getDb, jobId, tasks, options) {
61
188
  if (tasks.length === 0)
62
189
  return [];
63
190
  const queue = [...tasks];
@@ -198,7 +325,7 @@ async function runShardTasksWithWorkerPool(jobId, tasks, options) {
198
325
  retryCount++;
199
326
  setTimeout(() => {
200
327
  queue.push(next);
201
- setAuditJobProgress(jobId, {
328
+ setAuditJobProgress(getDb(), jobId, {
202
329
  phase: 'analysis',
203
330
  message: `Retrying shard ${next.shardId} (${next.attempts}/${options.maxRetries}) after worker recycle`,
204
331
  current: completedResults.length,
@@ -287,7 +414,7 @@ async function runShardTasksWithWorkerPool(jobId, tasks, options) {
287
414
  return;
288
415
  const overallCurrent = completedResults.length +
289
416
  Math.min(1, (message.progress.current ?? 0) / Math.max(1, message.progress.total ?? 1));
290
- setAuditJobProgress(jobId, {
417
+ setAuditJobProgress(getDb(), jobId, {
291
418
  phase: message.progress.phase ?? 'analysis',
292
419
  message: `${message.shardId}: ${message.progress.message ?? 'running'} (retries=${retryCount})`,
293
420
  current: Math.floor(overallCurrent),
@@ -308,7 +435,7 @@ async function runShardTasksWithWorkerPool(jobId, tasks, options) {
308
435
  attempts: 0,
309
436
  config: message.continuation,
310
437
  });
311
- setAuditJobProgress(jobId, {
438
+ setAuditJobProgress(getDb(), jobId, {
312
439
  phase: 'analysis',
313
440
  message: `Chunk done for ${entry.task.shardId}; queued ${message.remainingFiles.length} remaining file(s) (retries=${retryCount})`,
314
441
  current: completedResults.length,
@@ -326,7 +453,7 @@ async function runShardTasksWithWorkerPool(jobId, tasks, options) {
326
453
  runningShards--;
327
454
  if (message.kind === 'worker-result') {
328
455
  completedResults.push(message.result);
329
- setAuditJobProgress(jobId, {
456
+ setAuditJobProgress(getDb(), jobId, {
330
457
  phase: 'analysis',
331
458
  message: `Completed shard ${entry.task.shardId} (${completedResults.length} chunk(s), retries=${retryCount})`,
332
459
  current: completedResults.length,
@@ -459,14 +586,99 @@ function mergeAnalyzerResult(base, next) {
459
586
  errors: [...(base.errors || []), ...(next.errors || [])],
460
587
  };
461
588
  }
589
+ /**
590
+ * Merge per-shard coverage rows into the project-wide coverage a synchronous
591
+ * (non-sharded) run would have produced. Every shard's `buildCoverageReport`
592
+ * iterates the full rule registry and emits, for rules whose analyzer that
593
+ * shard did not run, a placeholder `notApplicable` row with reason
594
+ * `analyzer "<name>" not in results`. Those placeholders are filtered out so a
595
+ * rule's real state comes only from the shards that actually ran its analyzer.
596
+ *
597
+ * Precedence across the remaining rows: `fired` wins (its count re-derived from
598
+ * the merged deduped violations — never the summed per-shard counts, which
599
+ * over-count DB-based analyzers that emit full-project findings in every
600
+ * shard), then `clean` (ran somewhere with input present and found nothing)
601
+ * over `unassessed`, over `notApplicable` (only when every shard that ran the
602
+ * rule reported no input). Global-only analyzers (GLOBAL_ONLY_ANALYZERS) run
603
+ * in a single shard over the full scope, so their rows — including Spec 39
604
+ * applicability predicates like `missing-org-filter` — pass through untouched.
605
+ */
606
+ function mergeCoverage(results, ordered) {
607
+ const rows = results.flatMap((r) => r.metadata?.coverage ?? []);
608
+ if (rows.length === 0)
609
+ return undefined;
610
+ const byRule = new Map();
611
+ for (const row of rows) {
612
+ const key = JSON.stringify([row.analyzer, row.ruleId]);
613
+ const group = byRule.get(key);
614
+ if (group)
615
+ group.push(row);
616
+ else
617
+ byRule.set(key, [row]);
618
+ }
619
+ const merged = [];
620
+ for (const [key, group] of byRule) {
621
+ const [analyzer, ruleId] = JSON.parse(key);
622
+ // Rows from shards that did not run this analyzer are placeholders, not a
623
+ // real state. Prefer the shards that ran it; fall back to placeholders only
624
+ // if the analyzer ran nowhere (then the placeholder's reason is truthful).
625
+ const real = group.filter((r) => r.reason !== `analyzer "${analyzer}" not in results`);
626
+ const src = real.length > 0 ? real : group;
627
+ const fired = src.filter((r) => r.state === 'fired');
628
+ if (fired.length > 0) {
629
+ // Derive the fired count from the merged (deduped) violations, exactly as
630
+ // the synchronous path's buildCoverageReport does. Summing per-shard
631
+ // counts would over-count if a DB-based analyzer ever ran in multiple
632
+ // shards (it no longer does — reducers are global-only), but deduping
633
+ // from the merged set stays correct regardless and keeps coverage.count
634
+ // in agreement with the ledger findings count.
635
+ const field = RULE_REGISTRY[ruleId]?.field;
636
+ const dedupedCount = (ordered[analyzer]?.violations ?? []).filter((v) => violationMatchesRule(v, ruleId, field)).length;
637
+ merged.push({
638
+ ruleId,
639
+ analyzer,
640
+ state: 'fired',
641
+ count: dedupedCount,
642
+ });
643
+ continue;
644
+ }
645
+ const clean = src.find((r) => r.state === 'clean');
646
+ if (clean) {
647
+ merged.push({ ruleId, analyzer, state: 'clean', count: 0 });
648
+ continue;
649
+ }
650
+ const unassessed = src.find((r) => r.state === 'unassessed');
651
+ if (unassessed) {
652
+ merged.push({ ruleId, analyzer, state: 'unassessed', count: 0 });
653
+ continue;
654
+ }
655
+ const notApplicable = src.find((r) => r.state === 'notApplicable');
656
+ if (notApplicable) {
657
+ merged.push({
658
+ ruleId,
659
+ analyzer,
660
+ state: 'notApplicable',
661
+ count: 0,
662
+ reason: notApplicable.reason,
663
+ });
664
+ continue;
665
+ }
666
+ merged.push({ ruleId, analyzer, state: src[0].state, count: src[0].count, reason: src[0].reason });
667
+ }
668
+ return merged;
669
+ }
462
670
  function mergeAuditResults(results, orderedAnalyzers) {
463
671
  const analyzerResults = {};
464
672
  const fileToFunctionsMap = {};
465
673
  const collectedFunctions = [];
466
674
  const recommendations = [];
675
+ const skippedFiles = [];
676
+ const unparsedFiles = [];
677
+ const diagnostics = [];
467
678
  let filesAnalyzed = 0;
468
679
  let auditDuration = 0;
469
680
  let provenanceResolutionMs = 0;
681
+ let tableCatalog;
470
682
  for (const result of results) {
471
683
  for (const [analyzerName, analyzerResult] of Object.entries(result.analyzerResults || {})) {
472
684
  analyzerResults[analyzerName] = mergeAnalyzerResult(analyzerResults[analyzerName], analyzerResult);
@@ -477,6 +689,18 @@ function mergeAuditResults(results, orderedAnalyzers) {
477
689
  if (result.metadata?.collectedFunctions) {
478
690
  collectedFunctions.push(...result.metadata.collectedFunctions);
479
691
  }
692
+ if (result.metadata?.skippedFiles)
693
+ skippedFiles.push(...result.metadata.skippedFiles);
694
+ if (result.metadata?.unparsedFiles)
695
+ unparsedFiles.push(...result.metadata.unparsedFiles);
696
+ if (result.metadata?.diagnostics)
697
+ diagnostics.push(...result.metadata.diagnostics);
698
+ // tableCatalog is produced by the schema analyzer, which is global-only and
699
+ // therefore runs in a single shard over the full scope — first non-undefined
700
+ // is the whole catalog, never a fragment.
701
+ if (result.metadata?.tableCatalog && tableCatalog === undefined) {
702
+ tableCatalog = result.metadata.tableCatalog;
703
+ }
480
704
  filesAnalyzed += result.metadata?.filesAnalyzed || 0;
481
705
  auditDuration += result.metadata?.auditDuration || 0;
482
706
  provenanceResolutionMs += result.metadata?.provenanceResolutionMs || 0;
@@ -488,6 +712,7 @@ function mergeAuditResults(results, orderedAnalyzers) {
488
712
  if (analyzerResults[name])
489
713
  ordered[name] = analyzerResults[name];
490
714
  }
715
+ const coverage = mergeCoverage(results, ordered);
491
716
  return {
492
717
  timestamp: new Date(),
493
718
  summary: summarizeAnalyzerResults(ordered, filesAnalyzed),
@@ -500,6 +725,11 @@ function mergeAuditResults(results, orderedAnalyzers) {
500
725
  provenanceResolutionMs,
501
726
  ...(collectedFunctions.length > 0 && { collectedFunctions }),
502
727
  ...(Object.keys(fileToFunctionsMap).length > 0 && { fileToFunctionsMap }),
728
+ ...(coverage !== undefined && { coverage }),
729
+ ...(tableCatalog !== undefined && { tableCatalog }),
730
+ ...(skippedFiles.length > 0 && { skippedFiles }),
731
+ ...(unparsedFiles.length > 0 && { unparsedFiles }),
732
+ ...(diagnostics.length > 0 && { diagnostics }),
503
733
  },
504
734
  };
505
735
  }
@@ -559,12 +789,15 @@ export async function derivePartitionPlan(args, projectRoot, isFile, enabledAnal
559
789
  }
560
790
  export async function startAuditJob(args, defaults) {
561
791
  const auditPath = path.resolve(args.path || process.cwd());
562
- await assertAuditPathExists(auditPath);
563
- const job = createAuditJob(auditPath);
792
+ const { isFile } = await assertAuditPathExists(auditPath);
793
+ const projectRoot = isFile ? path.dirname(auditPath) : auditPath;
794
+ const db = CodeIndexDB.getInstance(undefined, projectRoot);
795
+ await db.initialize();
796
+ const job = createAuditJob(db.rawDb, projectRoot, { surface: 'mcp', command: 'audit.start' });
564
797
  setTimeout(() => {
565
798
  void runAuditJob(job.jobId, args, defaults).catch((err) => {
566
799
  try {
567
- patchAuditJob(job.jobId, {
800
+ patchAuditJob(db.rawDb, job.jobId, {
568
801
  status: 'failed',
569
802
  finishedAt: new Date().toISOString(),
570
803
  error: err instanceof Error ? err.message : String(err),
@@ -582,19 +815,47 @@ export async function startAuditJob(args, defaults) {
582
815
  path: auditPath,
583
816
  };
584
817
  }
585
- async function runAuditJob(jobId, args, defaults) {
818
+ export async function runAuditJob(jobId, args, defaults) {
586
819
  let jobTimer;
820
+ let heartbeat;
821
+ // Declared as `| undefined` so a failure thrown before the DB handle resolves
822
+ // is still catchable; getJobDb() asserts it via `!` and the catch block
823
+ // swallows the resulting throw rather than masking the original error.
824
+ let db;
587
825
  const ac = new AbortController();
826
+ // All progress/heartbeat writes go through the singleton connection — there is
827
+ // no transient second connection. A detached run forks shard workers, each of
828
+ // which opens its own SQLite connection to the shared per-project DB;
829
+ // better-sqlite3 opens its fd O_CLOEXEC, so forked children never inherit this
830
+ // parent's fd. A second open connection to the same WAL database left open
831
+ // into the indexing loop participates in the shared -shm state and makes the
832
+ // parent's deferred read→write transactions fail with "database is locked" —
833
+ // the defect that originally motivated a separate progress connection, so this
834
+ // stays a single connection throughout.
835
+ const getJobDb = () => db.rawDb;
588
836
  try {
589
- patchAuditJob(jobId, {
590
- status: 'running',
591
- startedAt: new Date().toISOString(),
592
- progress: {
593
- phase: 'queued',
594
- message: 'Audit queued',
595
- },
596
- });
597
837
  const auditPath = path.resolve(args.path || process.cwd());
838
+ const { isFile } = await assertAuditPathExists(auditPath);
839
+ const projectRoot = isFile ? path.dirname(auditPath) : auditPath;
840
+ // Open the per-project DB so a detached child resolves the same DB as the
841
+ // parent, not the child's cwd.
842
+ db = CodeIndexDB.getInstance(undefined, projectRoot);
843
+ await db.initialize();
844
+ // Spec 41 R5 — heartbeat lease in the prologue, before queued→running.
845
+ await acquireLease(db.rawDb, projectRoot, jobId);
846
+ patchAuditJob(db.rawDb, jobId, {
847
+ progress: { phase: 'queued', message: 'Audit queued' },
848
+ });
849
+ const startedMs = Date.now();
850
+ const heartbeatMs = Math.max(1000, Math.floor(jobLeaseTtlMs() / 2));
851
+ heartbeat = setInterval(() => {
852
+ try {
853
+ patchLedgerRun(getJobDb(), jobId, { heartbeatAt: new Date().toISOString() });
854
+ }
855
+ catch (e) {
856
+ // best-effort — a lost beat only matters after the lease TTL expires
857
+ }
858
+ }, heartbeatMs);
598
859
  const indexFunctions = args.indexFunctions !== false;
599
860
  const generateCodeMap = args.generateCodeMap ?? defaults.defaultGenerateCodeMap;
600
861
  const jobTimeoutMs = Math.min(ABSOLUTE_MAX_JOB_TIMEOUT_MS, Math.max(MIN_JOB_TIMEOUT_MS, Number(args.jobTimeoutMs) || defaultJobTimeoutMs()));
@@ -606,15 +867,11 @@ async function runAuditJob(jobId, args, defaults) {
606
867
  ac.abort();
607
868
  }
608
869
  }, jobTimeoutMs);
609
- const { isFile } = await assertAuditPathExists(auditPath);
610
- const db = CodeIndexDB.getInstance();
611
- await db.initialize();
612
870
  const storedConfigs = await db.getAllAnalyzerConfigs(auditPath);
613
871
  const analyzerConfigs = {
614
872
  ...storedConfigs,
615
873
  ...(args.analyzerConfigs || {}),
616
874
  };
617
- const projectRoot = isFile ? path.dirname(auditPath) : auditPath;
618
875
  const enabledAnalyzers = args.analyzers || defaults.defaultAnalyzers;
619
876
  const maxWorkers = Math.max(1, Math.min(MAX_AUDIT_WORKERS, Number(args.workerCount) || Math.max(1, Math.min(4, cpus().length - 1 || 1)), Number(args.maxPartitions) || 4));
620
877
  const maxRetries = Math.max(0, Number(args.maxRetries) || 1);
@@ -641,7 +898,7 @@ async function runAuditJob(jobId, args, defaults) {
641
898
  ...(Object.keys(analyzerConfigs).length > 0 && { analyzerConfigs }),
642
899
  ...(args.scope && args.scope !== 'all' && { scope: args.scope }),
643
900
  progressCallback: (p) => {
644
- setAuditJobProgress(jobId, {
901
+ setAuditJobProgress(getJobDb(), jobId, {
645
902
  phase: p.phase ?? 'analysis',
646
903
  message: p.message ?? p.phase ?? 'running',
647
904
  current: typeof p.current === 'number' ? p.current : undefined,
@@ -650,21 +907,30 @@ async function runAuditJob(jobId, args, defaults) {
650
907
  },
651
908
  };
652
909
  const plan = await derivePartitionPlan(args, projectRoot, isFile, enabledAnalyzers);
653
- const shardTasks = [];
910
+ // Spec 41 R3 — provenance: capture an aggregate content hash + per-file
911
+ // manifest so `result`/`status` can report staleness cheaply.
912
+ const fileHash = hashFileSet(await findFiles(projectRoot), projectRoot);
913
+ patchLedgerRun(db.rawDb, jobId, {
914
+ contentHash: fileHash.contentHash,
915
+ filesCount: fileHash.filesCount,
916
+ fileManifestJson: JSON.stringify(fileHash.manifest),
917
+ });
918
+ const partitionTasks = [];
919
+ const globalTasks = [];
654
920
  if (plan.mode === 'none') {
655
- shardTasks.push({
921
+ partitionTasks.push({
656
922
  shardId: 'full-scope',
657
923
  attempts: 0,
658
924
  config: asSerializableConfig(baseOptions),
659
925
  });
660
926
  }
661
927
  else {
662
- setAuditJobProgress(jobId, {
928
+ setAuditJobProgress(getJobDb(), jobId, {
663
929
  phase: 'partitioning',
664
930
  message: `Planning ${plan.partitionPaths.length} shard(s) + ${plan.globalAnalyzers.length > 0 ? 'global' : 'no-global'} analyzers`,
665
931
  });
666
932
  if (plan.globalAnalyzers.length > 0) {
667
- shardTasks.push({
933
+ globalTasks.push({
668
934
  shardId: 'global-analyzers',
669
935
  attempts: 0,
670
936
  config: asSerializableConfig({
@@ -675,7 +941,7 @@ async function runAuditJob(jobId, args, defaults) {
675
941
  });
676
942
  }
677
943
  for (const partitionPath of plan.partitionPaths) {
678
- shardTasks.push({
944
+ partitionTasks.push({
679
945
  shardId: `shard:${path.basename(partitionPath)}`,
680
946
  attempts: 0,
681
947
  config: asSerializableConfig({
@@ -686,19 +952,32 @@ async function runAuditJob(jobId, args, defaults) {
686
952
  });
687
953
  }
688
954
  }
689
- setAuditJobProgress(jobId, {
955
+ const totalTasks = partitionTasks.length + globalTasks.length;
956
+ setAuditJobProgress(getJobDb(), jobId, {
690
957
  phase: 'analysis',
691
- message: `Running ${shardTasks.length} shard task(s) with ${maxWorkers} worker(s)`,
958
+ message: `Running ${totalTasks} shard task(s) with ${maxWorkers} worker(s)`,
692
959
  current: 0,
693
- total: shardTasks.length,
960
+ total: totalTasks,
694
961
  });
695
- const resultParts = await runShardTasksWithWorkerPool(jobId, shardTasks, {
962
+ const poolOptions = {
696
963
  maxWorkers,
697
964
  maxRetries,
698
965
  shardTimeoutMs,
699
966
  retryBackoffMs,
700
967
  signal: ac.signal,
701
- });
968
+ };
969
+ // Partition shards run first; the global-analyzers shard is scheduled only
970
+ // after every partition shard has completed. The global shard's full-scope
971
+ // reducers (conventions, cross-domain, styles) read cross-file index tables
972
+ // (`functions`, `function_calls`, `style_*`) that the always-on function-index
973
+ // visitor writes from *every* shard. Running it concurrently — even pushed
974
+ // "first" — left a `functions`-table write/read race in principle. Serializing
975
+ // it after the partitions closes that last multi-writer window structurally.
976
+ const resultParts = [];
977
+ resultParts.push(...(await runShardTasksWithWorkerPool(getJobDb, jobId, partitionTasks, poolOptions)));
978
+ if (globalTasks.length > 0) {
979
+ resultParts.push(...(await runShardTasksWithWorkerPool(getJobDb, jobId, globalTasks, poolOptions)));
980
+ }
702
981
  if (ac.signal.aborted) {
703
982
  throw ac.signal.reason instanceof Error
704
983
  ? ac.signal.reason
@@ -707,18 +986,14 @@ async function runAuditJob(jobId, args, defaults) {
707
986
  const auditResult = resultParts.length === 1 ? resultParts[0] : mergeAuditResults(resultParts, enabledAnalyzers);
708
987
  let indexingResult = null;
709
988
  if (indexFunctions && auditResult.metadata.fileToFunctionsMap) {
710
- const syncStats = { added: 0, updated: 0, removed: 0 };
711
- for (const [filePath, functions] of Object.entries(auditResult.metadata.fileToFunctionsMap)) {
712
- if (ac.signal.aborted) {
713
- throw ac.signal.reason instanceof Error
714
- ? ac.signal.reason
715
- : new Error(String(ac.signal.reason || 'Audit job was cancelled during indexing'));
716
- }
717
- const fileStats = await syncFileIndex(filePath, functions);
718
- syncStats.added += fileStats.added;
719
- syncStats.updated += fileStats.updated;
720
- syncStats.removed += fileStats.removed;
989
+ if (ac.signal.aborted) {
990
+ throw ac.signal.reason instanceof Error
991
+ ? ac.signal.reason
992
+ : new Error(String(ac.signal.reason || 'Audit job was cancelled during indexing'));
721
993
  }
994
+ // Batch all per-file upserts into a single transaction (Amendment B2).
995
+ const entries = Object.entries(auditResult.metadata.fileToFunctionsMap).map(([filePath, functions]) => ({ filePath, currentFunctions: functions }));
996
+ const syncStats = await db.syncFileIndexBatch(entries);
722
997
  indexingResult = {
723
998
  success: true,
724
999
  registered: syncStats.added + syncStats.updated,
@@ -771,7 +1046,11 @@ async function runAuditJob(jobId, args, defaults) {
771
1046
  ...(codeMapResult && { codeMap: codeMapResult }),
772
1047
  };
773
1048
  const resultId = await db.storeAuditResults(persisted, projectRootForStore);
774
- patchAuditJob(jobId, {
1049
+ // Spec 41 R2 — converge on the one ledger write path: attach findings +
1050
+ // coverage to the pre-existing run, then close the lifecycle.
1051
+ const runInput = detectRunInput('audit.start', 'mcp', args.scope || 'all', projectRoot, PACKAGE_VERSION);
1052
+ writeAuditToLedger(db.rawDb, runInput, getAllViolations(auditResult), Date.now() - startedMs, 0, { runId: jobId, coverage: auditResult.metadata.coverage });
1053
+ patchAuditJob(db.rawDb, jobId, {
775
1054
  status: 'completed',
776
1055
  finishedAt: new Date().toISOString(),
777
1056
  progress: { phase: 'completed', message: 'Audit completed' },
@@ -779,21 +1058,47 @@ async function runAuditJob(jobId, args, defaults) {
779
1058
  });
780
1059
  }
781
1060
  catch (e) {
782
- patchAuditJob(jobId, {
783
- status: 'failed',
784
- finishedAt: new Date().toISOString(),
785
- error: e instanceof Error ? e.message : String(e),
786
- progress: { phase: 'failed', message: 'Audit failed' },
787
- });
1061
+ // Write the full trace to stderr (the detached child's per-run log) so a
1062
+ // failure is diagnosable without re-running — the error row alone drops the
1063
+ // stack that says *where* the run died.
1064
+ try {
1065
+ process.stderr.write(`[code-auditor] job ${jobId} failed: ${e instanceof Error ? (e.stack || e.message) : String(e)}\n`);
1066
+ }
1067
+ catch {
1068
+ // ignore — the log write is best-effort
1069
+ }
1070
+ // Record the failure through the singleton connection (best-effort — never
1071
+ // mask the original error, and the run already failed). If the error was
1072
+ // thrown before the DB handle resolved, getJobDb() itself throws and is
1073
+ // swallowed here.
1074
+ try {
1075
+ patchAuditJob(getJobDb(), jobId, {
1076
+ status: 'failed',
1077
+ finishedAt: new Date().toISOString(),
1078
+ error: e instanceof Error ? e.message : String(e),
1079
+ progress: { phase: 'failed', message: 'Audit failed' },
1080
+ });
1081
+ }
1082
+ catch {
1083
+ // best-effort — the run already failed; never mask the original error
1084
+ }
788
1085
  }
789
1086
  finally {
1087
+ if (heartbeat !== undefined) {
1088
+ clearInterval(heartbeat);
1089
+ }
790
1090
  if (jobTimer !== undefined) {
791
1091
  clearTimeout(jobTimer);
792
1092
  }
793
1093
  }
794
1094
  }
795
- export function getAuditJobStatus(jobId) {
796
- const job = getAuditJob(jobId);
1095
+ export async function getAuditJobStatus(jobId) {
1096
+ // Resolve the same DB the writer opened: startAuditJob/runAuditJob set the
1097
+ // singleton to the project-scoped DB, so read from the current singleton's
1098
+ // project root rather than cwd (which would flip the singleton away).
1099
+ const db = CodeIndexDB.getInstance(undefined, CodeIndexDB.currentProject);
1100
+ await db.initialize();
1101
+ const job = getAuditJob(db.rawDb, jobId);
797
1102
  if (!job) {
798
1103
  throw new ContextualError(`Audit job not found: ${jobId}`, {
799
1104
  jobId,
@@ -822,7 +1127,7 @@ export async function getAuditResultsAsSarif(args) {
822
1127
  hint: 'Call audit.start, poll audit.status until completed, then pass resultId to audit.results with format: "sarif".',
823
1128
  });
824
1129
  }
825
- const db = CodeIndexDB.getInstance();
1130
+ const db = CodeIndexDB.getInstance(undefined, CodeIndexDB.currentProject);
826
1131
  await db.initialize();
827
1132
  const stored = await db.getAuditResults(resultId);
828
1133
  if (!stored) {
@@ -867,7 +1172,7 @@ export async function getAuditResultsPage(args) {
867
1172
  }
868
1173
  const limit = Math.min(Math.max(0, Number(args.limit)) || 50, 100);
869
1174
  const offset = Math.max(0, Number(args.offset) || 0);
870
- const db = CodeIndexDB.getInstance();
1175
+ const db = CodeIndexDB.getInstance(undefined, CodeIndexDB.currentProject);
871
1176
  await db.initialize();
872
1177
  const auditResult = await db.getAuditResults(resultId);
873
1178
  if (!auditResult) {
@@ -907,5 +1212,6 @@ export async function getAuditResultsPage(args) {
907
1212
  export const __testables = {
908
1213
  isRetryableShardError,
909
1214
  mergeAnalyzerResult,
1215
+ mergeCoverage,
910
1216
  };
911
1217
  //# sourceMappingURL=mcpAuditJobs.js.map