pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
217 lines (216 loc) • 9.48 kB
JavaScript
/**
* Publishes `pilens:diagnostics` on pi's shared `pi.events` bus (#502).
*
* Sibling to `clients/bus-publish.ts` (the #482 `pilens:files:touched`
* producer) rather than a new export crammed into that file: the two events
* share the emit plumbing (`wireBusEmitter`) and the `PI_LENS_BUS_PUBLISH`
* kill switch, but this producer owns its OWN piece of module state (the
* previously-reported-paths set for clean-transition tracking, the seq
* counter) that has nothing to do with files-touched.
*
* ## CONSUMER CONTRACT — staleness / replace semantics (2026-07-11 design, #502)
*
* Diagnostics are STATE; bus events are point-in-time snapshots of a slice of
* that state. To guarantee a consumer can always reconstruct the latest
* known picture from the event stream alone, this producer follows LSP
* `publishDiagnostics` semantics:
*
* 1. **Full-replace per file, never a delta.** Every event carries the
* COMPLETE current diagnostic set for each file it mentions. An event
* mentioning path P replaces everything a consumer previously held for P
* — never merge/append across events for the same path.
* 2. **Empty array = explicitly clean.** When a previously-reported file's
* diagnostics clear, this producer emits `{path, diagnostics: []}` for it
* exactly once, on the transition. Silence never means clean (the #240
* doctrine, applied here on the producer side) — a consumer that stops
* hearing about a path has learned NOTHING about its current state.
* 3. **Monotonic `seq` + `ts` per emission.** `seq` increments once per
* `publishDiagnostics` call (module-level counter, process-lifetime
* monotonic — never reset except in tests). Out-of-order receipt resolves
* deterministically: higher `seq` always wins, lower is discarded.
* 4. **`pilens:files:touched` (#482) is an INVALIDATION HINT, not new data.**
* Between an edit landing (a files:touched event) and the next
* diagnostics batch for that path, a consumer's previously-held
* diagnostics for that path are PROVISIONAL — the file has changed on
* disk but pi-lens hasn't re-analyzed it yet. Consumers that want to
* avoid rendering stale annotations across that window should treat a
* files:touched path as "diagnostics pending" until the next
* pilens:diagnostics event mentions it (at any seq).
*
* Late-joiners are a non-problem in-process: extensions activate at
* `session_start`, before any turn emits, so v1 is push-only (no
* request/replay). #478's future `pilens:rpc:diagnostics` pull API reuses
* this exact `PilensDiagnosticsPayload` shape verbatim — push and pull are
* two deliveries of the same schema over the same lens-engine seam; #478
* stays separately gated on #449 registry dogfooding.
*
* ## Emission seam
*
* `publishDiagnostics` is called once per write batch immediately after
* `recordDiagnostics` (clients/widget-state.ts) commits the FINAL per-file
* diagnostic set for that batch — i.e. after format, autofix, and dispatch
* have all run (see pipeline.ts's phase order). This guarantees the emitted
* event reflects post-batch latest state, not an intermediate runner result:
* widget-state's `allDiagnostics` store is exactly what `recordDiagnostics`
* just wrote, so reading it back at the same call site can't race a later
* write in the same batch.
*
* Versioning policy: frozen-additive, same discipline as #482. New optional
* fields may be added under `v: 1`; a breaking change to an existing field's
* meaning must bump `v`.
*/
import { logBusEvent } from "./bus-events-logger.js";
import { normalizeFilePath } from "./path-utils.js";
import { isBusPublishEnabled } from "./bus-publish.js";
export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
export const BUS_DIAGNOSTICS_VERSION = 1;
/** Max diagnostics carried per file per event — aligned with the widget's own per-file storage cap (`MAX_STORED_DIAGNOSTICS_PER_FILE`, clients/widget-state.ts). */
export const MAX_DIAGNOSTICS_PER_FILE_EVENT = 12;
let busEmit;
let hasLoggedFailure = false;
let hasLoggedUnwired = false;
let hasLoggedDisabled = false;
let seqCounter = 0;
/** Paths this producer has reported with at least one non-empty diagnostics array, so we know when to fire the one-time clean-transition event. */
const reportedDirtyPaths = new Set();
/**
* Wire the emit function from pi's `pi.events` bus. Called once at extension
* factory time from index.ts, same call as `wireBusEmitter` (#482) — both
* producers share the identical `pi.events.emit` binding.
*/
export function wireDiagnosticsBusEmitter(emitFn) {
busEmit = emitFn;
}
/** Test-only: reset module state between test files. */
export function _resetDiagnosticsPublishForTests() {
busEmit = undefined;
hasLoggedFailure = false;
hasLoggedUnwired = false;
hasLoggedDisabled = false;
seqCounter = 0;
reportedDirtyPaths.clear();
}
function capDiagnostics(diagnostics) {
if (diagnostics.length <= MAX_DIAGNOSTICS_PER_FILE_EVENT) {
return { capped: diagnostics, truncated: false };
}
// Prioritize errors first (same "blockers first" spirit as the widget cap).
const errors = diagnostics.filter((d) => d.severity === "error");
const rest = diagnostics.filter((d) => d.severity !== "error");
if (errors.length >= MAX_DIAGNOSTICS_PER_FILE_EVENT) {
return {
capped: errors.slice(0, MAX_DIAGNOSTICS_PER_FILE_EVENT),
truncated: true,
};
}
return {
capped: [
...errors,
...rest.slice(0, MAX_DIAGNOSTICS_PER_FILE_EVENT - errors.length),
],
truncated: true,
};
}
/**
* Publish one `pilens:diagnostics` event for a write batch's final
* per-file diagnostic state. Fire-and-forget: never throws, never awaited.
*
* Full-replace semantics (see module doc): each `files` entry here is
* treated as the COMPLETE current set for that path. Additionally, for
* every path in `reportedDirtyPaths` that is NOT present in this call's
* `files` list but has gone clean via a prior call in THIS same invocation,
* callers must pass an explicit `{path, diagnostics: []}` entry — this
* function does not infer clean transitions for paths it isn't told about.
* The one caller (the pipeline write-batch seam) always passes the
* single file it just analyzed, so the common clean-transition path is:
* dispatch returns zero diagnostics for a file this producer previously
* reported dirty -> caller passes `{path, diagnostics: []}` -> emitted once,
* `reportedDirtyPaths` drops the path so a SECOND clean run of the same file
* does not re-emit (still clean -> silence is fine once the transition
* itself has been announced).
*/
export function publishDiagnostics(args) {
if (args.origin === "bus")
return;
if (args.files.length === 0)
return;
if (!isBusPublishEnabled()) {
if (!hasLoggedDisabled) {
hasLoggedDisabled = true;
logBusEvent({
event: BUS_DIAGNOSTICS_EVENT,
outcome: "skipped_disabled",
cwd: normalizeFilePath(args.cwd),
});
}
return;
}
if (!busEmit) {
if (!hasLoggedUnwired) {
hasLoggedUnwired = true;
logBusEvent({
event: BUS_DIAGNOSTICS_EVENT,
outcome: "skipped_unwired",
cwd: normalizeFilePath(args.cwd),
});
}
return;
}
try {
const fileEntries = args.files.map((f) => {
const normPath = normalizeFilePath(f.path);
const { capped, truncated } = capDiagnostics(f.diagnostics);
if (capped.length > 0) {
reportedDirtyPaths.add(normPath);
}
else {
reportedDirtyPaths.delete(normPath);
}
const entry = {
path: normPath,
diagnostics: capped,
};
if (truncated)
entry.truncated = true;
return entry;
});
seqCounter += 1;
const payload = {
v: BUS_DIAGNOSTICS_VERSION,
source: "pi-lens",
cwd: normalizeFilePath(args.cwd),
seq: seqCounter,
ts: Date.now(),
files: fileEntries,
};
busEmit(BUS_DIAGNOSTICS_EVENT, payload);
logBusEvent({
event: BUS_DIAGNOSTICS_EVENT,
outcome: "emitted",
cwd: payload.cwd,
fileCount: payload.files.length,
seq: payload.seq,
});
}
catch (err) {
logBusEvent({
event: BUS_DIAGNOSTICS_EVENT,
outcome: "emit_failed",
cwd: normalizeFilePath(args.cwd),
error: String(err),
});
if (!hasLoggedFailure) {
hasLoggedFailure = true;
args.dbg?.(`diagnostics-publish: pilens:diagnostics emit failed (further failures suppressed): ${err}`);
}
}
}
/**
* Whether `path` was last reported with a non-empty diagnostic set (i.e. a
* clean run for it now would be a transition worth emitting `[]` for).
* Exposed for the pipeline call site to decide whether to include a
* currently-clean file in the batch it passes to `publishDiagnostics`.
*/
export function wasPreviouslyReportedDirty(path) {
return reportedDirtyPaths.has(normalizeFilePath(path));
}