pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
328 lines (325 loc) • 15.6 kB
JavaScript
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url);
import {
BoundedLruCache
} from "./chunk-ABBOA7UD.js";
import {
normalizeMapKey
} from "./chunk-Q5U6FMDI.js";
// dist/clients/dispatch/fact-store.js
var capacityEvictionReporter;
function setFactStoreEvictionReporter(reporter) {
capacityEvictionReporter = reporter;
}
function getFactStoreEvictionReporter() {
return capacityEvictionReporter;
}
var MAX_FILE_FACT_RECORDS = 1024;
var MAX_FILE_FACT_CONTENT_BYTES = 64 * 1024 * 1024;
var MAX_SESSION_FACT_RECORDS = 4096;
var FactStore = class {
subject;
// Insertion order is eviction order: the oldest-used record is evicted first.
fileFacts = /* @__PURE__ */ new Map();
sessionFacts = /* @__PURE__ */ new Map();
// Bounded sibling of `sessionFacts` for per-file-keyed session facts (#2282).
// True LRU: every fetch (get or create, see getBoundedSessionFact) refreshes
// recency, so the oldest-UNUSED record is evicted first, not merely the
// oldest-inserted.
boundedSessionFacts = new BoundedLruCache(MAX_SESSION_FACT_RECORDS);
// Keys evicted from `boundedSessionFacts` and not written since, so a reader
// can tell "evicted" from "never seen" (#2282). Same cap, same FIFO order.
evictedSessionFactKeys = /* @__PURE__ */ new Set();
// Pin refcount per file. A file is exempt from capacity eviction while its
// count is > 0. Refcounted, not a Set, so two overlapping dispatches of the
// same file both hold the pin until BOTH settle (#2243 item 2).
pinnedFiles = /* @__PURE__ */ new Map();
// Running UTF-8 byte total for file.content only. Maintaining it at every
// mutation keeps getFileFact/setFileFact O(1) amortized; never scan the map
// to decide whether the byte budget is exceeded.
retainedContentBytes = 0;
// Subset of retainedContentBytes held by currently pinned files (#2247
// review F2). Maintained incrementally the same way — a scan over
// `pinnedFiles` looks small in isolation, but `evictColdFileFacts` calls it
// on EVERY unpinned insert for as long as pinned bytes stay over budget, so
// a scan there would re-walk a large pinned file's full string on every
// single write admitted during that window. Keeping a running total avoids
// reintroducing exactly the scan `retainedContentBytes` exists to avoid.
pinnedContentBytesTotal = 0;
/**
* @param subject Discriminates this store's capacity-eviction telemetry
* from every other production FactStore's (#2243 review round 3, F1).
* Production callers pass a label naming the store (e.g. `"dispatch"`,
* `"runtime-session-call-graph"`); the default is only for call sites
* that never wired a reporter and tests that don't care.
*/
constructor(subject = "session-fact-store") {
this.subject = subject;
}
// All file-keyed methods normalize the path internally via normalizeMapKey().
// Callers always pass raw/resolved paths — normalization is not their concern.
getFileFact(filePath, factId) {
return this.touchFileFacts(normalizeMapKey(filePath))?.get(factId);
}
setFileFact(filePath, factId, value) {
const key = normalizeMapKey(filePath);
let facts = this.touchFileFacts(key);
if (!facts) {
facts = /* @__PURE__ */ new Map();
this.fileFacts.set(key, facts);
}
if (factId === "file.content") {
const delta = this.contentBytes(value) - this.contentBytes(facts.get(factId));
this.retainedContentBytes += delta;
if (this.pinnedFiles.has(key))
this.pinnedContentBytesTotal += delta;
}
facts.set(factId, value);
this.evictColdFileFacts();
}
hasFileFact(filePath, factId) {
return this.fileFacts.get(normalizeMapKey(filePath))?.has(factId) ?? false;
}
/** Drop one file fact without disturbing the file's derived facts.
* Removes the per-file entry too when the deleted fact was its last value. */
deleteFileFact(filePath, factId) {
const key = normalizeMapKey(filePath);
const facts = this.fileFacts.get(key);
if (!facts)
return;
if (factId === "file.content") {
const dropped = this.contentBytes(facts.get(factId));
this.retainedContentBytes -= dropped;
if (this.pinnedFiles.has(key))
this.pinnedContentBytesTotal -= dropped;
}
facts.delete(factId);
if (facts.size === 0)
this.fileFacts.delete(key);
}
/** Clear facts for one specific file only, and mark the file's dispatch as
* begun. Use at the start of each per-file dispatch call.
* Preserves facts for other files computed in the same turn.
* Normalizes filePath internally — callers pass raw paths.
* Pins the file against capacity eviction until the matching
* {@link endDispatchFor} runs in the dispatch's settle path (#2243 item 2).
* Every `clearFileFactsFor` MUST be paired with an `endDispatchFor`. */
clearFileFactsFor(filePath) {
const key = normalizeMapKey(filePath);
this.deleteFileFactsRecord(key);
this.pinFile(key);
}
/** Pin a file against capacity eviction for the duration of a dispatch,
* WITHOUT clearing its existing facts. Pairs with {@link endDispatchFor}
* exactly like {@link clearFileFactsFor} does.
*
* #2243 review round 3 (F5): `clearFileFactsFor`'s clear+re-derive is
* right for a dispatch driven by a fresh edit, but the debounced
* ast-grep warning scan runs ~2s AFTER the inline dispatch for that same
* edit already re-derived every fact for this exact file — a second
* clear there bought nothing but ~51ms of redundant re-parsing across
* all five providers (measured; a warm-cache read is ~1ms). Use this
* where the facts are very likely already fresh: `runProviders` still
* re-derives any fact this file is missing (a genuine miss, e.g. the
* fact was evicted meanwhile), so a read never depends on staleness. */
beginDispatchFor(filePath) {
this.pinFile(normalizeMapKey(filePath));
}
/** Release the pin a {@link clearFileFactsFor} or {@link beginDispatchFor}
* took for one file's dispatch. Call once per matching call, from the
* dispatch's finally/settle path, so the pin set tracks dispatches
* actually in flight rather than the last N files touched (#2243 item 2). */
endDispatchFor(filePath) {
this.unpinFile(normalizeMapKey(filePath));
this.evictColdFileFacts();
}
/** Clear one file's facts WITHOUT pinning. For sequential single-store scans
* (project-diagnostics) that own their store and race no concurrent walk:
* pinning there would exempt every scanned file from the capacity cap and
* defeat it. Normalizes filePath internally. */
dropFileFacts(filePath) {
this.deleteFileFactsRecord(normalizeMapKey(filePath));
}
/** LRU touch: re-inserting an existing record moves it to the end of the
* eviction order, so a record still in use is not the next victim. */
touchFileFacts(key) {
const facts = this.fileFacts.get(key);
if (!facts)
return void 0;
this.fileFacts.delete(key);
this.fileFacts.set(key, facts);
return facts;
}
pinFile(key) {
const count = this.pinnedFiles.get(key) ?? 0;
if (count === 0) {
this.pinnedContentBytesTotal += this.contentBytes(this.fileFacts.get(key)?.get("file.content"));
}
this.pinnedFiles.set(key, count + 1);
}
unpinFile(key) {
const count = this.pinnedFiles.get(key);
if (count === void 0)
return;
if (count <= 1) {
this.pinnedFiles.delete(key);
this.pinnedContentBytesTotal -= this.contentBytes(this.fileFacts.get(key)?.get("file.content"));
} else {
this.pinnedFiles.set(key, count - 1);
}
}
/** Drop least-recently-used records past either cap, skipping the files whose
* dispatch pinned them. Pinned content counts toward the byte total, but is
* never evicted before endDispatchFor. Pins survive until clearAll.
*
* #2247 review F2: a leaked (or several overlapping) pin(s) can put pinned
* bytes over budget ON THEIR OWN. Evicting every unpinned record can never
* bring the total back under budget in that state — before this guard, the
* loop below evicted each newly inserted unpinned record on the very next
* call (it was always the sole remaining unpinned key), so unpinned writes
* never retained anything: a silent, permanent collapse for the rest of
* the process. Once byte pressure is unpinned-bytes-driven-only, stop
* evicting and admit unpinned inserts as-is — best-effort under pin
* pressure — and record the state distinctly so it is visible instead of
* inferred from "the store stopped retaining anything". */
evictColdFileFacts() {
if (!this.overCapacity())
return;
for (const key of this.fileFacts.keys()) {
if (this.pinnedFiles.has(key))
continue;
const axis = this.capacityAxis();
if (axis === "bytes" && this.pinnedContentBytesTotal > MAX_FILE_FACT_CONTENT_BYTES) {
this.reportPinnedOverBudget();
return;
}
this.deleteFileFactsRecord(key);
this.reportCapacityEviction(key, axis);
if (!this.overCapacity())
return;
}
}
overCapacity() {
return this.fileFacts.size > MAX_FILE_FACT_RECORDS || this.retainedContentBytes > MAX_FILE_FACT_CONTENT_BYTES;
}
capacityAxis() {
return this.fileFacts.size > MAX_FILE_FACT_RECORDS ? "count" : "bytes";
}
contentBytes(value) {
return typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0;
}
deleteFileFactsRecord(key) {
const facts = this.fileFacts.get(key);
if (!facts)
return;
const dropped = this.contentBytes(facts.get("file.content"));
this.retainedContentBytes -= dropped;
if (this.pinnedFiles.has(key))
this.pinnedContentBytesTotal -= dropped;
this.fileFacts.delete(key);
}
// #2243 item 4: the cap silently drops a fact a live dispatch may read back
// as "" (dispatcher.ts reads `file.content` with `?? ""`). Emit through the
// injected reporter so the drop is visible in the ledger instead of being
// inferred from a downstream symptom. integration.ts routes this to
// `recordDegradationOnce` keyed on THIS store's `subject` (#2243 review
// round 3, F1), so the ledger's own per-kind+subject dedupe yields exactly
// ONE record per session PER STORE (re-arming at session_start when the
// ledger resets) — no latch or generation compare here, so fact-store
// keeps no session-scoped state to forget to re-arm.
reportCapacityEviction(evictedKey, axis) {
capacityEvictionReporter?.(this.subject, axis, axis === "count" ? `file-fact store axis=count exceeded ${MAX_FILE_FACT_RECORDS} records; evicted least-recently-used fact for ${evictedKey}` : `file-fact store axis=bytes exceeded ${MAX_FILE_FACT_CONTENT_BYTES} retained content bytes; evicted least-recently-used fact for ${evictedKey}`);
}
// #2247 review F2: pinned bytes alone exceeding budget is not an eviction —
// nothing is dropped — so it is reported through a distinct axis rather
// than reusing `reportCapacityEviction`'s "evicted least-recently-used
// fact for X" language, which would misdescribe what happened.
reportPinnedOverBudget() {
const pinnedBytes = this.pinnedContentBytesTotal;
capacityEvictionReporter?.(this.subject, "pinned-over-budget", `file-fact store pinned content bytes (${pinnedBytes}) exceed the ${MAX_FILE_FACT_CONTENT_BYTES}-byte budget; unpinned inserts are admitted without eviction until a pin releases`);
}
/** Running UTF-8 byte total for retained `file.content` values. Exposed so
* tests can crosscheck it against a fresh sum over retained records
* (#2247 review F3) — the running total is maintained incrementally on
* every insert/delete path rather than recomputed, so a subtraction
* dropped from one of those paths would otherwise drift silently. */
getRetainedContentBytes() {
return this.retainedContentBytes;
}
/** Running UTF-8 byte total for the pinned SUBSET of retained
* `file.content` values. Exposed so tests can crosscheck it against a
* fresh sum over `pinnedFiles ∩ fileFacts` (#2247 review F4) — like
* `retainedContentBytes`, it is maintained incrementally on the pin
* 0→1 transition, the unpin 1→0 transition, content overwrite/delete,
* and `clearAll`, so a drop from any one of those paths would
* otherwise drift silently instead of failing loudly. */
getPinnedContentBytes() {
return this.pinnedContentBytesTotal;
}
getSessionFact(factId) {
return this.sessionFacts.get(factId);
}
setSessionFact(factId, value) {
this.sessionFacts.set(factId, value);
}
hasSessionFact(factId) {
return this.sessionFacts.has(factId);
}
/** Bounded counterpart to {@link getSessionFact} for per-file-keyed session
* facts (#2282) — session baselines, cascade baselines, review-graph entity
* snapshots. LRU touch: a read moves the record off the eviction end. */
getBoundedSessionFact(factId) {
if (!this.boundedSessionFacts.has(factId))
return void 0;
return this.boundedSessionFacts.get(factId);
}
/** Bounded counterpart to {@link setSessionFact}. Callers use this instead
* of `setSessionFact` for any family that mints one key per file/entity
* touched, so the store's count stays bounded across a large batch instead
* of retaining one entry per distinct path for the process lifetime. */
setBoundedSessionFact(factId, value) {
this.evictedSessionFactKeys.delete(factId);
const evicted = this.boundedSessionFacts.set(factId, value);
for (const [oldestKey] of evicted) {
this.evictedSessionFactKeys.add(oldestKey);
while (this.evictedSessionFactKeys.size > MAX_SESSION_FACT_RECORDS) {
const oldestTombstone = this.evictedSessionFactKeys.values().next().value;
if (oldestTombstone === void 0)
break;
this.evictedSessionFactKeys.delete(oldestTombstone);
}
capacityEvictionReporter?.(this.subject, "session-count", `session-fact store axis=count exceeded ${MAX_SESSION_FACT_RECORDS} records; evicted least-recently-used fact for ${oldestKey}`);
}
}
hasBoundedSessionFact(factId) {
return this.boundedSessionFacts.has(factId);
}
/** True when this key held a bounded session fact that capacity eviction
* dropped and nothing has rewritten since (#2282). Readers whose "absent"
* branch is not a safe default — the review-graph entity snapshot, where
* absent would read as "every symbol is new" — use this to tell an evicted
* key from one this session has never seen. */
wasBoundedSessionFactEvicted(factId) {
return this.evictedSessionFactKeys.has(factId);
}
/** O(1) entry count for memory-attribution sampling (#2282 Observability):
* the fixed-vocabulary map plus the bounded per-file map, so a caller gets
* this store's whole `sessionFacts` footprint in one number. */
getSessionFactEntryCount() {
return this.sessionFacts.size + this.boundedSessionFacts.size;
}
/** Call on session reset only. Clears everything including tool cache and baselines. */
clearAll() {
this.fileFacts.clear();
this.sessionFacts.clear();
this.boundedSessionFacts.clear();
this.evictedSessionFactKeys.clear();
this.pinnedFiles.clear();
this.retainedContentBytes = 0;
this.pinnedContentBytesTotal = 0;
}
};
export {
setFactStoreEvictionReporter,
getFactStoreEvictionReporter,
FactStore
};