code-auditor-mcp 3.7.0 → 3.8.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.
Files changed (121) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +43 -1
  3. package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
  4. package/dist/analyzers/cross-language/SchemaValidator.js +122 -43
  5. package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
  6. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +2 -2
  7. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +6 -7
  8. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
  9. package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
  10. package/dist/analyzers/ruleRegistry.js +19 -18
  11. package/dist/analyzers/ruleRegistry.js.map +1 -1
  12. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +9 -1
  13. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
  14. package/dist/analyzers/universal/UniversalDRYAnalyzer.js +63 -44
  15. package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
  16. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
  17. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +76 -28
  18. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
  19. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +73 -13
  20. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
  21. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +12 -2
  22. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
  23. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +30 -18
  24. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
  25. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +2 -8
  26. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
  27. package/dist/analyzers/universal/UniversalStylesAnalyzer.js +54 -45
  28. package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
  29. package/dist/analyzers/universal/functionConcerns.d.ts +9 -4
  30. package/dist/analyzers/universal/functionConcerns.d.ts.map +1 -1
  31. package/dist/analyzers/universal/functionConcerns.js +25 -11
  32. package/dist/analyzers/universal/functionConcerns.js.map +1 -1
  33. package/dist/analyzers/universal/schema/codeAnalysis.js +4 -4
  34. package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -1
  35. package/dist/analyzers/universal/schema/jsonSchema.js +124 -11
  36. package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -1
  37. package/dist/auditRouter.d.ts.map +1 -1
  38. package/dist/auditRouter.js +4 -6
  39. package/dist/auditRouter.js.map +1 -1
  40. package/dist/auditRunner.js +1 -1
  41. package/dist/auditRunner.js.map +1 -1
  42. package/dist/cli.js +284 -75
  43. package/dist/cli.js.map +1 -1
  44. package/dist/config/configLoader.d.ts.map +1 -1
  45. package/dist/config/configLoader.js +23 -0
  46. package/dist/config/configLoader.js.map +1 -1
  47. package/dist/config/defaults.d.ts +3 -0
  48. package/dist/config/defaults.d.ts.map +1 -1
  49. package/dist/config/defaults.js +30 -8
  50. package/dist/config/defaults.js.map +1 -1
  51. package/dist/conventions/conventionMiner.d.ts +18 -3
  52. package/dist/conventions/conventionMiner.d.ts.map +1 -1
  53. package/dist/conventions/conventionMiner.js +130 -12
  54. package/dist/conventions/conventionMiner.js.map +1 -1
  55. package/dist/daemon/core.d.ts +128 -0
  56. package/dist/daemon/core.d.ts.map +1 -0
  57. package/dist/daemon/core.js +700 -0
  58. package/dist/daemon/core.js.map +1 -0
  59. package/dist/daemon/lspServer.d.ts +51 -0
  60. package/dist/daemon/lspServer.d.ts.map +1 -0
  61. package/dist/daemon/lspServer.js +168 -0
  62. package/dist/daemon/lspServer.js.map +1 -0
  63. package/dist/daemon/main.d.ts +34 -0
  64. package/dist/daemon/main.d.ts.map +1 -0
  65. package/dist/daemon/main.js +147 -0
  66. package/dist/daemon/main.js.map +1 -0
  67. package/dist/daemon/resolve.d.ts +41 -0
  68. package/dist/daemon/resolve.d.ts.map +1 -0
  69. package/dist/daemon/resolve.js +90 -0
  70. package/dist/daemon/resolve.js.map +1 -0
  71. package/dist/daemon/socketClient.d.ts +21 -0
  72. package/dist/daemon/socketClient.d.ts.map +1 -0
  73. package/dist/daemon/socketClient.js +60 -0
  74. package/dist/daemon/socketClient.js.map +1 -0
  75. package/dist/daemon/socketServer.d.ts +33 -0
  76. package/dist/daemon/socketServer.d.ts.map +1 -0
  77. package/dist/daemon/socketServer.js +106 -0
  78. package/dist/daemon/socketServer.js.map +1 -0
  79. package/dist/daemon/types.d.ts +78 -0
  80. package/dist/daemon/types.d.ts.map +1 -0
  81. package/dist/daemon/types.js +10 -0
  82. package/dist/daemon/types.js.map +1 -0
  83. package/dist/dataPaths.d.ts +21 -0
  84. package/dist/dataPaths.d.ts.map +1 -1
  85. package/dist/dataPaths.js +27 -0
  86. package/dist/dataPaths.js.map +1 -1
  87. package/dist/hooks/core.d.ts.map +1 -1
  88. package/dist/hooks/core.js +21 -4
  89. package/dist/hooks/core.js.map +1 -1
  90. package/dist/languages/RuntimeManager.js +10 -0
  91. package/dist/languages/RuntimeManager.js.map +1 -1
  92. package/dist/languages/go/analyzer +0 -0
  93. package/dist/languages/go/analyzer-src/analyzer.go +166 -35
  94. package/dist/languages/go/analyzer-src/solid.go +80 -172
  95. package/dist/languages/go/analyzer-src/types.go +18 -18
  96. package/dist/ledger.d.ts +1 -1
  97. package/dist/ledger.d.ts.map +1 -1
  98. package/dist/ledger.js +3 -3
  99. package/dist/ledger.js.map +1 -1
  100. package/dist/mcpAuditJobs.js +1 -1
  101. package/dist/mcpAuditJobs.js.map +1 -1
  102. package/dist/nextFileIncremental.d.ts +9 -3
  103. package/dist/nextFileIncremental.d.ts.map +1 -1
  104. package/dist/nextFileIncremental.js +32 -6
  105. package/dist/nextFileIncremental.js.map +1 -1
  106. package/dist/pipeline.d.ts +26 -0
  107. package/dist/pipeline.d.ts.map +1 -1
  108. package/dist/pipeline.js +86 -10
  109. package/dist/pipeline.js.map +1 -1
  110. package/dist/pipelineAdapters.d.ts +1 -1
  111. package/dist/pipelineAdapters.js +1 -1
  112. package/dist/reporting/csvReportGenerator.js +1 -1
  113. package/dist/ruleAliases.d.ts.map +1 -1
  114. package/dist/ruleAliases.js +41 -2
  115. package/dist/ruleAliases.js.map +1 -1
  116. package/dist/types.d.ts +23 -1
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js.map +1 -1
  119. package/package.json +5 -2
  120. package/plugin/.claude-plugin/plugin.json +1 -1
  121. package/plugin/skills/code-auditor/SKILL.md +6 -6
@@ -0,0 +1,700 @@
1
+ /**
2
+ * Spec 50 — `DaemonCore`: the daemon's heart. Owns the store and the pipeline
3
+ * (R1); the LSP and Unix-socket faces are adapters over it.
4
+ *
5
+ * The core reuses the Spec 41 incremental machinery wholesale: `hashAndStatFiles`
6
+ * / `diffFiles` / `splitFindings` / `mergeFindings` from `nextFileIncremental.ts`.
7
+ * The seed is a full `runAuditDispatch` (the same entry `code-audit audit` uses,
8
+ * so counts match — acceptance 10); a warm re-audit is a scoped dispatch merged
9
+ * per-analyzer, exactly the `next-file` warm path made continuous.
10
+ *
11
+ * Concurrency (R5) uses the Spec 41 lease: a `findings_ledger_runs` row with
12
+ * `status='running'`, PID + hostname + start-time, heartbeated. A second daemon
13
+ * that cannot bind the socket (Face B) exits; a crashed daemon's lease is
14
+ * reclaimed by the next start via `reclaimStaleRunning`.
15
+ */
16
+ import { EventEmitter } from 'node:events';
17
+ import { watch, statSync, existsSync, readFileSync } from 'node:fs';
18
+ import { hostname } from 'node:os';
19
+ import path from 'node:path';
20
+ import { createHash } from 'node:crypto';
21
+ import { runAuditDispatch } from '../auditRouter.js';
22
+ import { CodeIndexDB } from '../codeIndexDB.js';
23
+ import { PACKAGE_VERSION } from '../constants.js';
24
+ import { createLedgerRun, writeAuditToLedger, patchLedgerRun, hashFileSet, reclaimStaleRunning, } from '../ledger.js';
25
+ import { findConfigFileUp, loadConfig } from '../config/configLoader.js';
26
+ import { discoverFilesDetailed, KNOWN_SOURCE_EXTENSIONS } from '../utils/fileDiscovery.js';
27
+ import { initParsers, LanguageRegistry } from '../languages/index.js';
28
+ import { hashAndStatFiles, diffFiles, splitFindings, mergeFindings, } from '../nextFileIncremental.js';
29
+ /** Extensions that carry table/DDL definitions — a change can shift the schema catalog. */
30
+ const SCHEMA_DEFINITION_EXTENSIONS = new Set(['.sql', '.prisma']);
31
+ /** Daemon lease TTL (longer than the 30s audit-job lease: a long-lived idle process). */
32
+ const DAEMON_LEASE_TTL_MS = Number(process.env.CODE_AUDITOR_DAEMON_LEASE_TTL_MS) || 60_000;
33
+ /** Debounce window for coalescing a burst of watcher events into one re-audit. */
34
+ const WATCH_DEBOUNCE_MS = 300;
35
+ /** Clamp for `retryAfterMs` so it is never absurdly short or long. */
36
+ const RETRY_AFTER_MIN_MS = 250;
37
+ const RETRY_AFTER_MAX_MS = 30_000;
38
+ export class DaemonCore extends EventEmitter {
39
+ projectRoot;
40
+ configName;
41
+ idleTimeoutMs;
42
+ status = 'starting';
43
+ snapshot = null;
44
+ progress = { filesIndexed: 0, filesTotal: 0, sourceTotal: 0, orphanTotal: 0 };
45
+ seedStartedAt = 0;
46
+ // Phase machine for `retryAfterMs`: the seed runs files → reducers → derived →
47
+ // finalize, and each phase is priced on its own observed rate so the estimate
48
+ // covers time-to-ready (never collapsing to "done" during the reducer tail).
49
+ phase = 'files';
50
+ phaseCurrent = 0;
51
+ phaseTotal = 0;
52
+ phaseStartedAt = 0;
53
+ orphanStartedAt = 0;
54
+ seenOrphan = false;
55
+ db = null;
56
+ leaseRunId = null;
57
+ heartbeat = null;
58
+ idleTimer = null;
59
+ watcher = null;
60
+ watchDebounce = null;
61
+ pendingWatchPaths = new Set();
62
+ reindexing = false;
63
+ lastActivityAt = Date.now();
64
+ activeConnections = 0;
65
+ shutdownRequested = false;
66
+ config = null;
67
+ constructor(options) {
68
+ super();
69
+ this.projectRoot = path.resolve(options.projectRoot);
70
+ this.configName = options.configName;
71
+ this.idleTimeoutMs = options.idleTimeoutMs;
72
+ }
73
+ /** Called by each face when a client connects/disconnects (idle tracking). */
74
+ registerConnection() {
75
+ this.activeConnections++;
76
+ this.markActivity();
77
+ }
78
+ unregisterConnection() {
79
+ this.activeConnections = Math.max(0, this.activeConnections - 1);
80
+ this.markActivity();
81
+ }
82
+ /** Faces call this on any client activity so the idle timer resets (R5). */
83
+ markActivity() {
84
+ this.lastActivityAt = Date.now();
85
+ }
86
+ getState() {
87
+ const state = {
88
+ status: this.status,
89
+ retryAfterMs: null,
90
+ throughputUnknown: false,
91
+ };
92
+ if (this.status === 'indexing' || this.status === 'reindexing') {
93
+ state.progress = {
94
+ ...this.progress,
95
+ phase: this.phase,
96
+ phaseCurrent: this.phaseCurrent,
97
+ phaseTotal: this.phaseTotal,
98
+ };
99
+ state.retryAfterMs = this.deriveRetryAfterMs();
100
+ state.throughputUnknown = state.retryAfterMs === null;
101
+ }
102
+ return state;
103
+ }
104
+ /**
105
+ * R3/R6 — derive `retryAfterMs` from observed progress across the seed's whole
106
+ * lifetime (files → reducers → derived → finalize), never a constant. Each
107
+ * phase is priced on its own observed rate; at a phase boundary the rate is
108
+ * unknown yet, so the value clamps conservatively instead of collapsing to
109
+ * "done" while work remains.
110
+ */
111
+ deriveRetryAfterMs() {
112
+ const now = Date.now();
113
+ if (this.phase === 'files')
114
+ return this.deriveFilesEta(now);
115
+ return this.derivePhaseEta(now);
116
+ }
117
+ /**
118
+ * Files phase — the two-population split. Source files (parsed, ~11ms each)
119
+ * are priced at the source rate; orphan files (raw, mostly no visitor) at the
120
+ * orphan rate. Discovery classifies both up front, so the split is known
121
+ * before the first sample. During the source phase the orphan rate is not yet
122
+ * observed, so orphans are priced at the source rate (the honest fallback —
123
+ * conservative, never an underestimate); once the source phase completes, the
124
+ * orphan tail is priced on its own (much faster) rate.
125
+ */
126
+ deriveFilesEta(now) {
127
+ const elapsedSec = (now - this.seedStartedAt) / 1000;
128
+ const { filesIndexed, filesTotal, sourceTotal } = this.progress;
129
+ if (filesIndexed <= 0 || elapsedSec <= 0)
130
+ return null; // throughput unknown yet
131
+ // Degenerate case: no source files — the whole corpus is orphan, priced at
132
+ // the cumulative rate (there is no source phase to complete first).
133
+ if (sourceTotal <= 0) {
134
+ const rate = filesIndexed / elapsedSec;
135
+ if (rate <= 0)
136
+ return null;
137
+ return this.clampRetry(((filesTotal - filesIndexed) / rate) * 1000);
138
+ }
139
+ const sourceIndexed = Math.min(filesIndexed, sourceTotal);
140
+ const orphanIndexed = Math.max(0, filesIndexed - sourceTotal);
141
+ if (sourceIndexed < sourceTotal) {
142
+ // Source phase: orphans priced at the source rate (fallback). This equals
143
+ // the cumulative estimate — pessimistic early, honest about what it knows.
144
+ const sourceRate = sourceIndexed / elapsedSec;
145
+ if (sourceRate <= 0)
146
+ return null;
147
+ const remaining = filesTotal - sourceIndexed; // source remaining + all orphans
148
+ return this.clampRetry((remaining / sourceRate) * 1000);
149
+ }
150
+ // Orphan phase: source is done; price the orphan tail on its own rate.
151
+ // (`orphanStartedAt` is set the first time `filesIndexed >= sourceTotal`; a
152
+ // still-unset boundary can only mean `orphanIndexed` is 0, which is handled
153
+ // by the `sourceIndexed < sourceTotal` branch above.)
154
+ const orphanElapsedSec = (now - this.orphanStartedAt) / 1000;
155
+ if (orphanElapsedSec <= 0)
156
+ return RETRY_AFTER_MAX_MS;
157
+ const orphanRate = orphanIndexed / orphanElapsedSec;
158
+ if (orphanRate <= 0)
159
+ return RETRY_AFTER_MAX_MS;
160
+ const orphanRemaining = this.progress.orphanTotal - orphanIndexed;
161
+ if (orphanRemaining <= 0)
162
+ return RETRY_AFTER_MIN_MS;
163
+ return this.clampRetry((orphanRemaining / orphanRate) * 1000);
164
+ }
165
+ /** Reducer/derived/finalize phases — uniform unit, priced on the phase's own window. */
166
+ derivePhaseEta(now) {
167
+ const { phaseCurrent, phaseTotal, phaseStartedAt } = this;
168
+ if (phaseTotal <= 0)
169
+ return RETRY_AFTER_MAX_MS; // no work priced in this phase — conservative
170
+ if (phaseCurrent <= 0)
171
+ return RETRY_AFTER_MAX_MS; // rate unknown yet, work known to remain
172
+ const phaseElapsedSec = (now - phaseStartedAt) / 1000;
173
+ if (phaseElapsedSec <= 0)
174
+ return RETRY_AFTER_MAX_MS;
175
+ const rate = phaseCurrent / phaseElapsedSec;
176
+ if (rate <= 0)
177
+ return RETRY_AFTER_MAX_MS;
178
+ const remaining = phaseTotal - phaseCurrent;
179
+ const rawMs = remaining <= 0 ? 0 : (remaining / rate) * 1000;
180
+ const eta = this.clampRetry(rawMs);
181
+ // The 250ms floor means "almost ready". That is only ever true in the final
182
+ // phase: in reducers/derived a later phase has not started, so a floor-level
183
+ // estimate would understate time-to-ready and must stay conservative.
184
+ if (this.phase !== 'finalize' && eta === RETRY_AFTER_MIN_MS)
185
+ return RETRY_AFTER_MAX_MS;
186
+ return eta;
187
+ }
188
+ clampRetry(ms) {
189
+ return Math.min(RETRY_AFTER_MAX_MS, Math.max(RETRY_AFTER_MIN_MS, Math.round(ms)));
190
+ }
191
+ /** Map pipeline + local progress events onto the phase machine (R3). */
192
+ onSeedProgress(p) {
193
+ const ph = p.phase;
194
+ if (ph === 'stage2') {
195
+ // File-level numerator — the pipeline also emits stage1 parse-failure
196
+ // events, but stage2's `current` is the authoritative (monotonic) file
197
+ // index, so track it exclusively and take a max for safety.
198
+ this.progress.filesIndexed = Math.max(this.progress.filesIndexed, p.current ?? 0);
199
+ this.assertSourceBeforeOrphan(p);
200
+ // Detect the source→orphan boundary (ordering invariant: source yields first).
201
+ if (this.orphanStartedAt === 0 && this.progress.sourceTotal > 0 && this.progress.filesIndexed >= this.progress.sourceTotal) {
202
+ this.orphanStartedAt = Date.now();
203
+ }
204
+ this.setPhase('files', this.progress.filesIndexed, this.progress.filesTotal);
205
+ }
206
+ else if (ph === 'stage3') {
207
+ this.setPhase('reducers', p.current ?? 0, p.total ?? 0);
208
+ }
209
+ else if (ph === 'stage4') {
210
+ this.setPhase('derived', p.current ?? 0, p.total ?? 0);
211
+ }
212
+ else if (ph === 'stage4-complete') {
213
+ // Pipeline is done; the daemon's own finalize (hash + persist) and any
214
+ // audit-runner post-pipeline awaits remain. Enter the finalize phase with
215
+ // no priced unit so the estimate clamps conservatively rather than
216
+ // reporting "done" at the end of the reducer tail.
217
+ this.setPhase('finalize', 0, 0);
218
+ }
219
+ else if (ph === 'finalize') {
220
+ this.setPhase('finalize', p.current ?? 0, p.total ?? 0);
221
+ }
222
+ // Ignore stage1 / stage1-complete / stage2-complete and any unknown phase.
223
+ this.emitState();
224
+ }
225
+ setPhase(next, current, total) {
226
+ if (next !== this.phase) {
227
+ this.phase = next;
228
+ this.phaseStartedAt = Date.now();
229
+ }
230
+ this.phaseCurrent = current;
231
+ this.phaseTotal = total;
232
+ }
233
+ /**
234
+ * Ordering invariant (R3 accuracy guard): stage 1 yields source files before
235
+ * orphans. The two-population split above interprets `filesIndexed` as "source
236
+ * done up to `sourceTotal`, then orphans" — a future reorder that interleaved
237
+ * the two loops would silently corrupt the estimate. Assert it loudly: throw
238
+ * the moment a source file is reported after an orphan has been seen.
239
+ */
240
+ assertSourceBeforeOrphan(p) {
241
+ if (p.file == null)
242
+ return;
243
+ const isSource = LanguageRegistry.getInstance().getAdapterForFile(p.file) !== null;
244
+ if (!isSource) {
245
+ this.seenOrphan = true;
246
+ }
247
+ else if (this.seenOrphan) {
248
+ throw new Error(`code-auditor daemon: stage-1 ordering violated — source file ${p.file} ` +
249
+ `yielded after orphan files; the two-population retryAfterMs estimate is invalid`);
250
+ }
251
+ }
252
+ /** All current findings + R4 stale-file report. */
253
+ getFindings() {
254
+ const staleFiles = this.checkStaleness();
255
+ return {
256
+ status: this.status,
257
+ violations: this.snapshot ? flattenSnapshot(this.snapshot) : [],
258
+ staleFiles,
259
+ };
260
+ }
261
+ /** Per-file findings (diagnostics) + R4 staleness for those files. */
262
+ getDiagnostics(files) {
263
+ const staleFiles = this.checkStaleness(files);
264
+ const diagnostics = [];
265
+ if (this.snapshot) {
266
+ for (const file of files) {
267
+ const key = path.relative(this.projectRoot, file);
268
+ for (const v of this.snapshot.visitorFindings[key] ?? []) {
269
+ diagnostics.push(v);
270
+ }
271
+ }
272
+ }
273
+ return { status: this.status, diagnostics, staleFiles };
274
+ }
275
+ /**
276
+ * True if a watcher-flagged path belongs to the audited corpus (R5). The
277
+ * watcher reports every change under the root — including the SQLite DB, WAL,
278
+ * and SHM files this process writes to `node_modules/.cache` — so the
279
+ * reconcile gate must filter to source extensions and never infra dirs, or a
280
+ * single persist would re-audit the corpus and loop.
281
+ */
282
+ isSourcePath(abs) {
283
+ const rel = path.relative(this.projectRoot, abs);
284
+ if (!rel || rel === '.' || rel.startsWith('..') || path.isAbsolute(rel))
285
+ return false;
286
+ const parts = rel.split(path.sep);
287
+ if (parts.some((p) => p === 'node_modules' || p === '.git' || p === '.cache'))
288
+ return false;
289
+ return KNOWN_SOURCE_EXTENSIONS.includes(path.extname(abs).toLowerCase());
290
+ }
291
+ /**
292
+ * R4 — validate that the served findings are current. Cheap-first: compare the
293
+ * recorded mtime; only when it diverges (or the file is new/missing) is the
294
+ * content re-hashed. `files` narrows the check to a specific read; `undefined`
295
+ * checks the whole recorded set (count + newest mtime fast path, full hash on
296
+ * divergence).
297
+ */
298
+ checkStaleness(files) {
299
+ if (!this.snapshot)
300
+ return [];
301
+ const stale = new Set();
302
+ if (files && files.length > 0) {
303
+ for (const file of files) {
304
+ const abs = path.isAbsolute(file) ? file : path.join(this.projectRoot, file);
305
+ const key = path.relative(this.projectRoot, abs);
306
+ const rec = this.snapshot.files[key];
307
+ try {
308
+ const st = statSync(abs);
309
+ if (rec && st.mtimeMs === rec.mtimeMs)
310
+ continue; // mtime unchanged → current
311
+ // mtime changed (or file is new): full hash to confirm.
312
+ const hash = sha256(readFileSync(abs));
313
+ if (!rec || hash !== rec.hash)
314
+ stale.add(key);
315
+ }
316
+ catch {
317
+ // Missing on disk: if we had recorded it, it's gone — stale; if never
318
+ // recorded, it isn't ours to serve.
319
+ if (rec)
320
+ stale.add(key);
321
+ }
322
+ }
323
+ return [...stale];
324
+ }
325
+ // Whole-set cheap pass (R4): stat each recorded file and compare its current
326
+ // mtime to the snapshot's recorded mtime. Any divergence (or a missing file)
327
+ // means the served set may be stale, so fall through to the full content-hash
328
+ // diff. This is O(n) stat — no readFileSync — so the common "nothing changed"
329
+ // read pays one stat per file, not one hash. A *content* change is always
330
+ // reflected in the mtime of a normal write; the mtime-preserved case is the
331
+ // job of the next-file `assertNoStaleFiles` gate, not this live read path.
332
+ const recorded = this.snapshot.files;
333
+ const rels = Object.keys(recorded);
334
+ let diverged = false;
335
+ for (const rel of rels) {
336
+ try {
337
+ const st = statSync(path.join(this.projectRoot, rel));
338
+ if (st.mtimeMs !== recorded[rel].mtimeMs) {
339
+ diverged = true;
340
+ break;
341
+ }
342
+ }
343
+ catch {
344
+ stale.add(rel); // missing on disk
345
+ diverged = true;
346
+ }
347
+ }
348
+ if (diverged || stale.size > 0) {
349
+ const current = hashAndStatFiles(rels.map((r) => path.join(this.projectRoot, r)), this.projectRoot);
350
+ const diff = diffFiles(recorded, current);
351
+ for (const rel of [...diff.changed, ...diff.deleted])
352
+ stale.add(rel);
353
+ }
354
+ return [...stale];
355
+ }
356
+ /** Start: init parsers, claim the lease, seed, watch. Resolves once seed completes. */
357
+ async start() {
358
+ this.status = 'starting';
359
+ await initParsers();
360
+ const configPath = await findConfigFileUp(this.projectRoot);
361
+ // Mirror the runner (`auditRunner.run`): load project config only when a
362
+ // `.codeauditor.json` exists. Loading defaults here would normalize their
363
+ // `includePaths`/`excludePaths` globs against *this process's* cwd (the
364
+ // install dir), not the served project — the seed would then discover zero
365
+ // files (R4's file-set hash would be empty) even though the pipeline's own
366
+ // discovery (defaults against the project root) finds the corpus.
367
+ this.config = configPath ? await loadConfig({ configPath }) : null;
368
+ this.db = CodeIndexDB.getInstance(undefined, this.projectRoot);
369
+ await this.db.initialize();
370
+ this.claimLease();
371
+ this.startIdleTimer();
372
+ await this.seed();
373
+ this.status = 'ready';
374
+ this.markActivity();
375
+ this.emitState();
376
+ this.startWatcher();
377
+ }
378
+ claimLease() {
379
+ if (!this.db)
380
+ return;
381
+ // Reclaim any stale daemon lease from a prior crashed instance (R5).
382
+ try {
383
+ reclaimStaleRunning(this.db.rawDb, this.projectRoot, DAEMON_LEASE_TTL_MS);
384
+ }
385
+ catch {
386
+ // best-effort — the socket bind is the primary exclusivity guard.
387
+ }
388
+ const runInput = {
389
+ gitDirty: false,
390
+ toolVersion: PACKAGE_VERSION,
391
+ command: 'daemon',
392
+ surface: 'daemon',
393
+ scope: 'all',
394
+ target: this.projectRoot,
395
+ };
396
+ this.leaseRunId = createLedgerRun(this.db.rawDb, runInput, {
397
+ status: 'running',
398
+ projectRoot: this.projectRoot,
399
+ });
400
+ const now = new Date().toISOString();
401
+ patchLedgerRun(this.db.rawDb, this.leaseRunId, {
402
+ startedAt: now,
403
+ heartbeatAt: now,
404
+ runnerPid: process.pid,
405
+ runnerPidStartedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
406
+ runnerHost: hostname(),
407
+ });
408
+ const heartbeatMs = Math.max(5000, Math.floor(DAEMON_LEASE_TTL_MS / 3));
409
+ this.heartbeat = setInterval(() => {
410
+ try {
411
+ if (this.leaseRunId && this.db) {
412
+ patchLedgerRun(this.db.rawDb, this.leaseRunId, { heartbeatAt: new Date().toISOString() });
413
+ }
414
+ }
415
+ catch {
416
+ // best-effort heartbeat
417
+ }
418
+ }, heartbeatMs);
419
+ this.heartbeat.unref?.();
420
+ }
421
+ startIdleTimer() {
422
+ this.idleTimer = setInterval(() => this.checkIdle(), 1000);
423
+ this.idleTimer.unref?.();
424
+ }
425
+ checkIdle() {
426
+ if (this.shutdownRequested)
427
+ return;
428
+ if (this.activeConnections > 0)
429
+ return;
430
+ if (this.status !== 'ready')
431
+ return;
432
+ if (Date.now() - this.lastActivityAt < this.idleTimeoutMs)
433
+ return;
434
+ void this.shutdown('idle timeout');
435
+ }
436
+ async seed() {
437
+ this.status = 'indexing';
438
+ this.seedStartedAt = Date.now();
439
+ this.markActivity();
440
+ const discovered = await discoverFilesDetailed(this.projectRoot, {
441
+ includePaths: this.config?.includePaths,
442
+ excludePaths: this.config?.excludePaths,
443
+ });
444
+ // Classify the corpus up front (the same `getAdapterForFile` the pipeline's
445
+ // stage 1 uses) so the two-population ETA knows the source/orphan split
446
+ // before the first sample.
447
+ const registry = LanguageRegistry.getInstance();
448
+ let sourceTotal = 0;
449
+ for (const file of discovered.files) {
450
+ if (registry.getAdapterForFile(file))
451
+ sourceTotal++;
452
+ }
453
+ const orphanTotal = discovered.files.length - sourceTotal;
454
+ this.progress = {
455
+ filesIndexed: 0,
456
+ filesTotal: discovered.files.length,
457
+ sourceTotal,
458
+ orphanTotal,
459
+ };
460
+ // Reset the phase machine for this seed.
461
+ this.phase = 'files';
462
+ this.phaseCurrent = 0;
463
+ this.phaseTotal = discovered.files.length;
464
+ this.phaseStartedAt = Date.now();
465
+ this.orphanStartedAt = 0;
466
+ this.seenOrphan = false;
467
+ this.emitState();
468
+ const start = Date.now();
469
+ const result = await runAuditDispatch({
470
+ projectRoot: this.projectRoot,
471
+ configName: this.configName,
472
+ progressCallback: (p) => this.onSeedProgress(p),
473
+ });
474
+ const durationMs = Date.now() - start;
475
+ // Finalize phase: split + hash + persist are the last work before `ready`.
476
+ this.setPhase('finalize', 0, discovered.files.length);
477
+ this.emitState();
478
+ const split = splitFindings(result.analyzerResults, this.projectRoot);
479
+ const files = hashAndStatFiles(discovered.files, this.projectRoot, (done) => {
480
+ this.setPhase('finalize', done, discovered.files.length);
481
+ this.emitState();
482
+ });
483
+ this.snapshot = {
484
+ version: 1,
485
+ projectRoot: this.projectRoot,
486
+ files,
487
+ visitorFindings: split.visitorFindings,
488
+ corpusFindings: split.corpusFindings,
489
+ schemaFindings: split.schemaFindings,
490
+ };
491
+ this.persistCurrent(flattenSnapshot(this.snapshot), durationMs);
492
+ }
493
+ /** Persist the current findings to the ledger (durable snapshot for the CLI). */
494
+ persistCurrent(violations, durationMs) {
495
+ if (!this.db)
496
+ return;
497
+ const runInput = {
498
+ gitDirty: false,
499
+ toolVersion: PACKAGE_VERSION,
500
+ command: 'daemon-audit',
501
+ surface: 'daemon',
502
+ scope: 'all',
503
+ target: this.projectRoot,
504
+ };
505
+ const runId = writeAuditToLedger(this.db.rawDb, runInput, violations, durationMs, 0);
506
+ if (this.snapshot) {
507
+ const fsh = hashFileSet(Object.keys(this.snapshot.files).map((r) => path.join(this.projectRoot, r)), this.projectRoot);
508
+ patchLedgerRun(this.db.rawDb, runId, {
509
+ contentHash: fsh.contentHash,
510
+ filesCount: fsh.filesCount,
511
+ fileManifestJson: JSON.stringify(fsh.manifest),
512
+ });
513
+ if (this.leaseRunId) {
514
+ patchLedgerRun(this.db.rawDb, this.leaseRunId, {
515
+ contentHash: fsh.contentHash,
516
+ filesCount: fsh.filesCount,
517
+ fileManifestJson: JSON.stringify(fsh.manifest),
518
+ });
519
+ }
520
+ }
521
+ }
522
+ startWatcher() {
523
+ try {
524
+ this.watcher = watch(this.projectRoot, { recursive: true }, (eventType, filename) => {
525
+ if (!filename)
526
+ return; // unknown change — R4 read-time check is the backstop
527
+ const abs = path.join(this.projectRoot, filename.toString());
528
+ // Ignore infra/non-source noise before it counts as activity or queues a
529
+ // reconcile. The daemon's own heartbeat writes the lease row into the
530
+ // SQLite DB under `node_modules/.cache` every ~20s; without this filter
531
+ // that would (a) reset the idle timer forever and (b) re-audit on every
532
+ // heartbeat. Only a source-path change is a corpus change.
533
+ if (!this.isSourcePath(abs))
534
+ return;
535
+ this.markActivity();
536
+ this.pendingWatchPaths.add(abs);
537
+ if (this.watchDebounce)
538
+ clearTimeout(this.watchDebounce);
539
+ this.watchDebounce = setTimeout(() => void this.reconcile(), WATCH_DEBOUNCE_MS);
540
+ this.watchDebounce.unref?.();
541
+ });
542
+ }
543
+ catch {
544
+ // Linux < 20 (or other) may not support recursive watch; the R4 read-time
545
+ // staleness check remains the correctness backstop. Log and continue.
546
+ console.error('[code-auditor-daemon] recursive fs.watch unavailable; relying on read-time staleness');
547
+ }
548
+ }
549
+ /** Warm re-audit of the files the watcher flagged. */
550
+ async reconcile() {
551
+ if (this.shutdownRequested || !this.snapshot)
552
+ return;
553
+ const paths = [...this.pendingWatchPaths];
554
+ this.pendingWatchPaths.clear();
555
+ if (paths.length === 0)
556
+ return;
557
+ if (this.reindexing) {
558
+ // A reconcile is already running — requeue and it will pick these up via
559
+ // the next diff, or via read-time staleness.
560
+ return;
561
+ }
562
+ this.reindexing = true;
563
+ const prevStatus = this.status;
564
+ this.status = 'reindexing';
565
+ this.emitState();
566
+ this.markActivity();
567
+ try {
568
+ // Classify each affected path as changed/added/deleted against the record.
569
+ const changed = [];
570
+ const added = [];
571
+ const deleted = [];
572
+ const freshFiles = {};
573
+ for (const abs of paths) {
574
+ // R5 — the watcher sees *everything* under the root, including the
575
+ // SQLite DB + WAL/SHM files this daemon writes to `node_modules/.cache`.
576
+ // A bare "something changed" signal would re-audit (near the full
577
+ // corpus) on every persist and loop. Only source paths are a corpus
578
+ // change; drop infra + non-source noise before classification.
579
+ if (!this.isSourcePath(abs))
580
+ continue;
581
+ const key = path.relative(this.projectRoot, abs);
582
+ const rec = this.snapshot.files[key];
583
+ if (!existsSync(abs)) {
584
+ if (rec)
585
+ deleted.push(key);
586
+ continue;
587
+ }
588
+ const rec2 = hashAndStatFiles([abs], this.projectRoot);
589
+ const entry = rec2[key];
590
+ if (!entry)
591
+ continue;
592
+ freshFiles[key] = entry;
593
+ if (!rec)
594
+ added.push(key);
595
+ else if (rec.hash !== entry.hash)
596
+ changed.push(key);
597
+ }
598
+ // Update the recorded file set for the affected files.
599
+ for (const key of Object.keys(freshFiles))
600
+ this.snapshot.files[key] = freshFiles[key];
601
+ for (const key of deleted)
602
+ delete this.snapshot.files[key];
603
+ if (changed.length === 0 && added.length === 0 && deleted.length === 0) {
604
+ return;
605
+ }
606
+ const scopeFiles = [...changed, ...added].map((r) => path.join(this.projectRoot, r));
607
+ const start = Date.now();
608
+ const result = await runAuditDispatch({
609
+ projectRoot: this.projectRoot,
610
+ configName: this.configName,
611
+ scope: scopeFiles.length > 0 ? scopeFiles : undefined,
612
+ });
613
+ const durationMs = Date.now() - start;
614
+ const fresh = splitFindings(result.analyzerResults, this.projectRoot);
615
+ // Schema-catalog hazard (mirrors next-file): a scoped run rebuilds the
616
+ // known-tables catalog from only the changed files, so a table-definition
617
+ // change can falsely flag unchanged query files `unknown-table`. Escalate
618
+ // to a full re-seed rather than serve silently-wrong schema findings.
619
+ const schemaCatalogTouched = (result.metadata?.tableCatalog?.length ?? 0) > 0;
620
+ const schemaDefinitionChanged = [...changed, ...added, ...deleted].some((rel) => SCHEMA_DEFINITION_EXTENSIONS.has(rel.slice(rel.lastIndexOf('.'))));
621
+ if (schemaCatalogTouched || schemaDefinitionChanged) {
622
+ await this.seed();
623
+ this.emitFindings();
624
+ return;
625
+ }
626
+ const merged = mergeFindings({
627
+ cachedVisitor: this.snapshot.visitorFindings,
628
+ freshVisitor: fresh.visitorFindings,
629
+ cachedCorpus: this.snapshot.corpusFindings,
630
+ freshCorpus: fresh.corpusFindings,
631
+ // Schema findings are fact-based and rebuilt from a full corpus; a scoped
632
+ // run only sees the changed files' facts, so its schema output is partial.
633
+ // Preserve the cached set (mirrors `runNextFile`) — the escalation above
634
+ // already re-seeded when a schema-definition file changed.
635
+ freshSchema: this.snapshot.schemaFindings,
636
+ changed,
637
+ added,
638
+ deleted,
639
+ });
640
+ this.snapshot.visitorFindings = merged.visitorFindings;
641
+ this.snapshot.corpusFindings = merged.corpusFindings;
642
+ this.snapshot.schemaFindings = merged.schemaFindings;
643
+ this.persistCurrent(flattenSnapshot(this.snapshot), durationMs);
644
+ this.emitFindings();
645
+ }
646
+ finally {
647
+ this.reindexing = false;
648
+ if (!this.shutdownRequested)
649
+ this.status = prevStatus;
650
+ this.markActivity();
651
+ this.emitState();
652
+ }
653
+ }
654
+ emitState() {
655
+ const state = this.getState();
656
+ this.emit('state', state);
657
+ }
658
+ emitFindings() {
659
+ if (this.snapshot)
660
+ this.emit('findings', this.snapshot);
661
+ }
662
+ async shutdown(reason = 'shutdown') {
663
+ if (this.shutdownRequested)
664
+ return;
665
+ this.shutdownRequested = true;
666
+ this.status = 'shutting-down';
667
+ this.emitState();
668
+ if (this.watchDebounce)
669
+ clearTimeout(this.watchDebounce);
670
+ if (this.watcher)
671
+ this.watcher.close();
672
+ if (this.heartbeat)
673
+ clearInterval(this.heartbeat);
674
+ if (this.idleTimer)
675
+ clearInterval(this.idleTimer);
676
+ if (this.db && this.leaseRunId) {
677
+ try {
678
+ patchLedgerRun(this.db.rawDb, this.leaseRunId, {
679
+ status: 'completed',
680
+ finishedAt: new Date().toISOString(),
681
+ });
682
+ }
683
+ catch {
684
+ // best-effort
685
+ }
686
+ }
687
+ this.emit('shutdown', reason);
688
+ }
689
+ }
690
+ function sha256(data) {
691
+ return createHash('sha256').update(data).digest('hex');
692
+ }
693
+ function flattenSnapshot(s) {
694
+ return [
695
+ ...Object.values(s.visitorFindings).flat(),
696
+ ...s.corpusFindings,
697
+ ...s.schemaFindings,
698
+ ];
699
+ }
700
+ //# sourceMappingURL=core.js.map