fix(proof): watch the real location of linked evidence, and tell clients when coverage changes (#1813)

Two gaps found in review of 85e6a849. Evidence reached through a link inside the project was watched only at its lexical parents, so replacing the real target directory went unseen; and a change in coverage with the same proof result was deduplicated away, so clients never learned the sources were lost or regained. The readers now accept an optional observer told each evidence path before its containment check (no extra reads; ordinary callers are unchanged). The watcher also watches each real location that stays inside the project, bounded by the real project's parent when the project is reached by an alias; for a path whose real location leaves the project it watches only the nearest ancestor that stays inside. Events are deduplicated on the revision and the observation state together.
This commit is contained in:
dev-engineer committed 2026-10-24 22:48:39 +00:00
1 parent 85e6a849d6
commit 646496438e
4 files changed
+96 -17

No files matched your search

@@ -167,8 +167,9 @@ export function evidenceAt(root: string, dir: string, ref: string): Evidence {
}
/** `root` (a selected catalog project's root) bounds the policy search and the scope identity; without it they derive
* from the nearest project.yaml, as for the selected workspace. */
export function readSliceReadiness(dir: string, io: ScopeFsDeps = proofFs, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string): ScopeReadiness {
* from the nearest project.yaml, as for the selected workspace. `observe`, when given, is told each evidence path
* before its containment check (a watcher's dependency metadata); it changes nothing about the evaluation. */
export function readSliceReadiness(dir: string, io: ScopeFsDeps = proofFs, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, observe?: (file: string) => void): ScopeReadiness {
let policy: ScopeReadiness["policy"] = null, receipts: Judgment[] = [], items: ItemReadiness[] = [];
const issues: string[] = [];
let policyRead = false;
@@ -191,6 +192,7 @@ export function readSliceReadiness(dir: string, io: ScopeFsDeps = proofFs, readP
if (!e || typeof e.ref !== "string" || typeof e.sha256 !== "string") return false;
try {
const address = parseAddress(e.ref), file = path.resolve(scopeRoot, address.ref);
observe?.(file);
if (io.readBytes) contained(scopeRoot, file);
const bytes = io.readBytes ? io.readBytes(file) : io.readFile(file);
if (bytes === null) return false;
@@ -295,7 +297,7 @@ export function recordJudgment(missionsRoot: string, input: JudgeInput, actor: s
* @param readPolicy - Reader function for determining applicable proof policy.
* @returns Evaluated mission readiness, including revision, state, slices, and any issues.
*/
export function readMissionReadiness(missionDir: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs): MissionReadiness {
export function readMissionReadiness(missionDir: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void): MissionReadiness {
const issues: string[] = [], slices: MissionReadiness["slices"] = [];
// #1280: each member's directory, for reading its declared node identity only when a dependency needs it.
const dirs = new Map<MissionReadiness["slices"][number], string>();
@@ -313,7 +315,7 @@ export function readMissionReadiness(missionDir: string, readPolicy: ProofPolicy
if (!Array.isArray(depends) || depends.some(x => typeof x !== "string")) throw new JudgmentError("dependencies_invalid", `${member.path}: execution.depends_on must be a list`);
const id = (data?.metadata as { id?: unknown } | undefined)?.id ?? path.basename(dir);
if (typeof id !== "string" || !id || slices.some(s => s.id === id)) throw new JudgmentError("dependency_identity", `${member.path}: missing or repeated slice identity`);
const slice = { scope: path.basename(dir), id, readiness: readSliceReadiness(dir, io, readPolicy, root), dependsOn: depends as string[], eligible: null };
const slice = { scope: path.basename(dir), id, readiness: readSliceReadiness(dir, io, readPolicy, root, observe), dependsOn: depends as string[], eligible: null };
slices.push(slice);
dirs.set(slice, dir);
}
@@ -362,8 +364,8 @@ function declaredNodeId(dir: string): string | null {
/** `io` lets a caller observe which files the evaluation reads (the proof source watcher derives its subscriptions
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs) {
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io) }));
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) }));
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
}
@@ -26,22 +26,44 @@ function add(out: Shallow, dir: string, name: string) {
out.set(dir, names);
}
/** The real location of `p`, resolving its nearest existing ancestor (as containment does), or null if unreadable. */
function canonical(p: string): string | null {
try {
let existing = path.resolve(p);
while (!fs.existsSync(existing)) existing = path.dirname(existing);
return path.resolve(fs.realpathSync(existing), path.relative(existing, path.resolve(p)));
} catch { return null; }
}
/** Where each path the evaluation read outside the missions root is observed: its parent, shallow, filtered to its
* name (for a missing parent, the nearest existing ancestor, filtered to the next segment toward it). Every watched
* directory, the missions root included, is also anchored at each ancestor up to `top`, filtered to the next segment,
* so a replaced or re-pointed directory on the way is seen and its watch rebound. */
function shallowWatches(missionsRoot: string, top: string, read: Iterable<string>): Shallow {
* name (for a missing parent, the nearest existing ancestor, filtered to the next segment toward it). A directory
* whose real location leaves the project is never watched: the nearest ancestor that stays inside is, filtered to
* the next segment. Every watched directory, the missions root included, is also anchored at each ancestor up to
* `top`, filtered to the next segment, so a replaced or re-pointed directory on the way is seen and its watch rebound. */
function shallowWatches(missionsRoot: string, top: string, project: string, read: Iterable<string>): Shallow {
const out: Shallow = new Map();
const inside = (dir: string) => { const real = canonical(dir); return real !== null && within(real, project); };
// The project may be reached by an alias: real-location paths are bounded by the real project's parent.
const realTop = path.dirname(project);
const topOf = (p: string) => (within(p, top) ? top : within(p, realTop) ? realTop : null);
for (const file of read) {
const target = path.resolve(file);
if (within(target, missionsRoot)) continue;
let child = target, dir = path.dirname(target);
while (dir !== child && !proofFs.exists(dir)) { child = dir; dir = path.dirname(dir); }
if (dir === child) continue;
const bound = topOf(dir);
if (bound === null) continue;
while (dir !== child && dir !== bound && !inside(dir)) { child = dir; dir = path.dirname(dir); }
if (dir === child || dir === bound) continue;
add(out, dir, path.basename(child));
}
for (const watched of [...out.keys(), missionsRoot]) {
for (let p = watched; within(p, top) && p !== top; p = path.dirname(p)) add(out, path.dirname(p), path.basename(p));
const bound = topOf(watched);
if (bound === null) continue;
for (let p = watched; p !== bound && within(p, bound); p = path.dirname(p)) {
const parent = path.dirname(p);
if (parent === bound || inside(parent)) add(out, parent, path.basename(p));
}
}
return out;
}
@@ -57,6 +79,8 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
let projectRoot = path.dirname(missionsRoot);
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
const top = path.dirname(path.resolve(projectRoot));
// The authority boundary in real terms: what the readers' containment check accepts.
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
// ponytail: bounded local workspaces use a full semantic read after a file burst.
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
const basis = () => {
@@ -69,8 +93,13 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
listDir: (p) => proofFs.listDir(note(p)),
isDirectory: (p) => proofFs.isDirectory(note(p)),
};
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io).missions.map(m => [m.name, m.revision]))).digest("hex");
return { hash, watches: shallowWatches(missionsRoot, top, read) };
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
// A file read through a link inside the project depends on its real location too: observe both.
for (const file of [...read]) {
const real = canonical(file);
if (real !== null && real !== path.resolve(file) && within(real, project)) read.add(real);
}
return { hash, watches: shallowWatches(missionsRoot, top, project, read) };
};
// failed latches for this instance: a watcher that errored is not trusted to be replaced by a later read.
let closed = false, failed = false, covered = false, readable = false;
@@ -139,12 +168,15 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
timer = undefined;
if (closed) return;
try {
const before = observed();
const next = evaluate();
readable = true;
if (next === revision) return;
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
// coverage is news even when the proof result itself did not change.
if (next === revision && observed() === before) return;
revision = next;
invalidate();
notify(revision);
notify(observed() ? revision : "unavailable");
} catch (e) {
// Never report the sources as watched after a failed read or a watch that could not be attached.
readable = false;
@@ -316,6 +316,21 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
expect(opened() === 2 || t.watch.observation().state === "unavailable").toBe(true);
} finally { t.watch.close(); }
});
it("watches the real location of evidence reached through a link inside the project", () => {
const t = adverseFixture();
try {
// The evidence directory becomes a link to another directory inside the project, with the same accepted bytes.
const real = path.join(t.f.root, "real"), live = path.join(real, "live");
t.f.write(path.join(live, "report.md"), "accepted observation");
fs.renameSync(t.outside, `${t.outside}.old`); fs.symlinkSync(live, t.outside);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); vi.advanceTimersByTime(50);
expect(readSliceReadiness(t.dir).state).toBe("ready");
// Replacing the real directory is seen at its own parent, or the sources are reported unobserved.
const watched = vi.mocked(fs.watch).mock.calls.map(([root]) => root);
expect(watched.includes(real) || t.watch.observation().state === "unavailable").toBe(true);
expect(watched.some((root) => !String(root).startsWith(t.f.root) && root !== path.dirname(t.f.root))).toBe(false);
} finally { t.watch.close(); }
});
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
const t = adverseFixture();
try {
@@ -4,7 +4,8 @@ const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot,
watch: vi.fn(), createProofPolicyRead: vi.fn(() => vi.fn()), readProjectReadiness: vi.fn(), resolveProjectRoot: vi.fn(),
proofFs: { exists: vi.fn(() => true), readFile: vi.fn(() => null), readBytes: vi.fn(() => null), listDir: vi.fn(() => []), isDirectory: vi.fn(() => true) },
}));
vi.mock("node:fs", () => ({ watch }));
// Real-location checks see every fixture path as itself (no links in these mocked trees).
vi.mock("node:fs", () => ({ watch, existsSync: () => true, realpathSync: (p: string) => p }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs }));
import { watchProofSources } from "../src/domain/proof/source-watch.js";
import type { EventBus } from "../src/domain/event-bus.js";
@@ -128,6 +129,35 @@ it("does not report watching when the inputs keep changing past the reconciliati
instance.close();
});
it("tells clients when coverage is lost or regained even though the proof result is unchanged", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
// Setup reads a stable set; the next event's three reads each find a new directory (same result); then it settles.
const stable = path.join(project, "project.yaml");
const moving = ["a", "b", "c"].map((d) => path.join(project, d, "report.md"));
let call = 0;
readProjectReadiness.mockImplementation((_m: string, _r: unknown, _root: unknown, io?: Io) => {
const n = call++;
io?.exists(stable);
if (n >= 2) io?.readBytes(moving[Math.min(n - 2, 2)]!);
return { missions: [] };
});
handle();
const emit = vi.fn();
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
expect(instance.observation().state).toBe("watching");
const notifyProject = watch.mock.calls[1]![2];
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable"]);
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
expect(instance.observation().state).toBe("watching");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable", instance.observation().revision]);
instance.close();
});
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");