From 840e3c00013257bb4b89b15e4f8b37cc45e5c1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Wed, 21 Oct 2026 18:34:05 +0200 Subject: [PATCH] fix: resolve candidate provenance in doctor (#146) Resolve candidate origins against retained session proposals, including processed stage-live sessions. Keep missing or malformed sources as warnings and preserve provenance metadata and existing resolution rules. Cover practice/map origins, invalid sources and indices, source removal, and unchanged node metadata. Document session-retention diagnostics. --- docs/internals/architecture.md | 2 + src/commands/doctor.ts | 34 +++++++- tests/doctor-dangling.test.ts | 151 ++++++++++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index a201bb4e..2ad93c42 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -81,6 +81,8 @@ Committed: `nodes/`, `ENTRY.md`, `GRAPH.md`, `FOLDER_SUMMARIES.md`, `.config/pro Per-run curator state lives under `_logs/curator/`: `__.draft.json` (a batch's actions and the sessions it consumed, with their transcript hashes) and the JSONL traces. `logs prune` deletes only the `.jsonl` files. +`doctor` resolves candidate provenance (`::`) against the referenced session log's frontmatter and indexed proposal, including processed logs staged by `session-log stage-live`. It preserves node metadata. Session logs are gitignored by default, so a missing source on another clone or after manual deletion still produces an advisory dangling-reference warning. `logsRetentionDays` applies only to `_logs/*.jsonl`, not `_sessions/*.md`. + ## Locking Only the proposal-drain hook locks, with `proper-lockfile` on `state.json`, a heartbeat, and a 60 second stale threshold. A drain the host kills is reclaimed by the next drain within a minute. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e6d9b1e7..394ce361 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -18,7 +18,13 @@ import { } from '../lib/nodes.js'; import { findRepoRoot, packageTemplatesDir, repoPaths } from '../lib/paths.js'; import { readRedirectsLedger, resolveRedirect } from '../lib/redirects.js'; -import { IndexFrontmatterSchema, SettingsSchema } from '../lib/schemas.js'; +import { + IndexFrontmatterSchema, + ProposalCandidateSchema, + SessionLogFrontmatterSchema, + SettingsSchema, +} from '../lib/schemas.js'; +import { findSessionLogBySessionId } from '../lib/session-log.js'; import { packageVersion } from '../lib/version.js'; const EXPECTED_PROMPTS = ['proposal-extract.md']; @@ -168,6 +174,8 @@ export async function runDoctor(opts: DoctorOptions): Promise { * - It is a bare filename and exists under `_sessions/`. * - It is a repo-relative path and exists when resolved against `root`. * - It is an absolute path and exists as-is. + * - It is a candidate identity whose session log contains the indexed + * practice/map proposal, including logs already processed by curation. * - It is a legacy retired id whose redirect chain * reaches at least one live node. Preserve these citations as recorded. */ @@ -184,6 +192,7 @@ export function collectDanglingDerivedFrom( for (const node of nodes) { for (const ref of node.frontmatter.kk_derived_from) { if (resolvesOnDisk(ref, root, sessionsDir)) continue; + if (resolvesCandidateOrigin(ref, sessionsDir)) continue; if (Object.hasOwn(redirects, ref) && resolveRedirect(redirects, liveIds, ref).length > 0) continue; out.push({ nodeId: node.frontmatter.kk_id, reference: ref }); @@ -201,6 +210,29 @@ function resolvesOnDisk(ref: string, root: string, sessionsDir: string): boolean return false; } +function resolvesCandidateOrigin(ref: string, sessionsDir: string): boolean { + const match = /^([^:/\\\s]+):(practice|map):(0|[1-9]\d*)$/.exec(ref); + if (!match) return false; + const [, sessionId, kind, rawIndex] = match; + if (!sessionId || (kind !== 'practice' && kind !== 'map')) return false; + const index = Number(rawIndex); + if (!Number.isSafeInteger(index)) return false; + try { + const filename = findSessionLogBySessionId(sessionsDir, sessionId); + if (!filename) return false; + const parsed = matter(readFileSync(join(sessionsDir, filename), 'utf8')); + const fm = SessionLogFrontmatterSchema.safeParse(parsed.data); + if (!fm.success || fm.data.session_id !== sessionId) return false; + // Provenance outlives queue eligibility: read the retained proposal even + // when a curator stamp makes this session ineligible for consumption. + const proposal = ProposalCandidateSchema.safeParse(fm.data.proposals[kind][index]); + return proposal.success && proposal.data.type === kind; + } catch { + // An unreadable or malformed source remains a dangling reference. + return false; + } +} + /** * Advisory freshness signal: how many nodes may describe source code that * changed since they were last curated. Always a warn (never a failure) so it diff --git a/tests/doctor-dangling.test.ts b/tests/doctor-dangling.test.ts index 94080f60..87f8cf8f 100644 --- a/tests/doctor-dangling.test.ts +++ b/tests/doctor-dangling.test.ts @@ -1,9 +1,48 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import matter from 'gray-matter'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { collectDanglingDerivedFrom } from '../src/commands/doctor.js'; +import { markSessionsProcessed } from '../src/lib/curate.js'; +import { buildSessionLogFilename, renderSessionLog } from '../src/lib/session-log.js'; +import { cliPath, runCli } from './helpers.js'; + +const SESSION_ID = 'b2225129-5ef2-4f05-a52f-c5e72c04d34c'; +const CAPTURED_AT = '2026-05-11T10:00:00Z'; +const PROPOSALS = { + practice: [ + { + type: 'practice', + tags: [], + title: 'Practice', + description: 's', + body: 'b', + kk_confidence: 'high', + }, + ], + map: [ + { type: 'map', tags: [], title: 'Map', description: 's', body: 'b', kk_confidence: 'high' }, + ], +}; + +function writeSession(harness: Harness): string { + const file = join(harness.sessionsDir, buildSessionLogFilename(CAPTURED_AT, SESSION_ID)); + writeFileSync( + file, + renderSessionLog({ + sessionId: SESSION_ID, + capturedBy: 'stop', + capturedAt: CAPTURED_AT, + transcriptHash: 'sha256:abc', + body: 'Transcript', + proposalStatus: 'done', + proposals: PROPOSALS, + }) + ); + return file; +} interface Harness { root: string; @@ -46,7 +85,13 @@ describe('collectDanglingDerivedFrom', () => { writeFileSync(join(harness.sessionsDir, 'session-a.md'), 'x'); mkdirSync(join(harness.root, 'docs'), { recursive: true }); writeFileSync(join(harness.root, 'docs/auth.md'), 'x'); - writeNode(harness, 'practice-a', ['session-a.md', 'docs/auth.md']); + writeNode(harness, 'practice-a', [ + 'session-a.md', + 'docs/auth.md', + join(harness.root, 'docs/auth.md'), + 'https://example.com/source', + 'file:///external/source.md', + ]); expect(collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir)).toEqual( [] ); @@ -75,4 +120,106 @@ describe('collectDanglingDerivedFrom', () => { ); expect(out).toEqual([]); }); + + it.each([false, true])( + 'resolves practice/map candidates in a session, processed=%s', + async processed => { + const file = writeSession(harness); + if (processed) { + await markSessionsProcessed( + [{ path: file, transcript_hash: 'sha256:abc' }], + 'run', + new Date(CAPTURED_AT) + ); + } + writeNode(harness, 'practice-a', [`${SESSION_ID}:practice:0`, `${SESSION_ID}:map:0`]); + const nodePath = join(harness.nodesDir, 'practice-a.md'); + const before = [readFileSync(nodePath, 'utf8'), readFileSync(file, 'utf8')]; + + expect( + collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir) + ).toEqual([]); + expect([readFileSync(nodePath, 'utf8'), readFileSync(file, 'utf8')]).toEqual(before); + } + ); + + it('reports missing sessions, missing proposals, invalid indices and malformed identities', () => { + const file = writeSession(harness); + const parsed = matter(readFileSync(file, 'utf8')); + parsed.data['proposals'] = { practice: PROPOSALS.practice, map: [] }; + writeFileSync(file, matter.stringify(parsed.content, parsed.data)); + const refs = [ + '11111111-2222-4333-8444-555555555555:practice:0', + `${SESSION_ID}:map:0`, + `${SESSION_ID}:practice:1`, + `${SESSION_ID}:practice:-1`, + `${SESSION_ID}:practice:01`, + `${SESSION_ID}:practice:0.5`, + `${SESSION_ID}:practice:9007199254740992`, + `${SESSION_ID}:practice:`, + `${SESSION_ID}:practice:0:extra`, + `${SESSION_ID}:unknown:0`, + ':practice:0', + ]; + writeNode(harness, 'practice-a', [`${SESSION_ID}:practice:0`, ...refs]); + expect(collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir)).toEqual( + refs.map(reference => ({ nodeId: 'practice-a', reference })) + ); + }); + + it('does not resolve an identity from a filename alone or an invalid proposal', () => { + const file = writeSession(harness); + writeNode(harness, 'practice-a', [`${SESSION_ID}:practice:0`]); + const original = readFileSync(file, 'utf8'); + const { data, content } = matter(original); + for (const contents of [ + '---\nbroken: [\n---\n', + matter.stringify(content, { ...data, session_id: 'another-session' }), + matter.stringify(content, { ...data, proposals: { practice: [null], map: [] } }), + matter.stringify(content, { ...data, proposals: { practice: PROPOSALS.map, map: [] } }), + ]) { + writeFileSync(file, contents); + expect( + collectDanglingDerivedFrom(harness.root, harness.nodesDir, harness.sessionsDir) + ).toEqual([{ nodeId: 'practice-a', reference: `${SESSION_ID}:practice:0` }]); + } + }); + + it('resolves a processed stage-live session in the built CLI and warns after its removal', async () => { + const staged = spawnSync( + process.execPath, + [cliPath, 'session-log', 'stage-live', '--session-id', SESSION_ID], + { + cwd: harness.root, + input: JSON.stringify(PROPOSALS), + encoding: 'utf8', + } + ); + expect(staged.status, staged.stderr).toBe(0); + const { path } = JSON.parse(staged.stdout) as { path: string }; + const parsed = matter(readFileSync(path, 'utf8')); + await markSessionsProcessed( + [{ path, transcript_hash: parsed.data['transcript_hash'] as string }], + 'run', + new Date(CAPTURED_AT) + ); + const refs = [`${SESSION_ID}:practice:0`, `${SESSION_ID}:map:0`]; + writeNode(harness, 'practice-a', refs); + const nodePath = join(harness.nodesDir, 'practice-a.md'); + const before = readFileSync(nodePath, 'utf8'); + const result = await runCli(harness.root, ['doctor', '--verbose']); + expect(result.stdout + result.stderr).toContain( + 'derived_from references resolve: no dangling references' + ); + expect(readFileSync(nodePath, 'utf8')).toBe(before); + + rmSync(path); + const missing = await runCli(harness.root, ['doctor', '--verbose']); + expect(missing.stdout + missing.stderr).toContain( + 'derived_from references resolve: 2 dangling reference(s)' + ); + for (const ref of refs) + expect(missing.stdout + missing.stderr).toContain(` - practice-a: ${ref}`); + expect(readFileSync(nodePath, 'utf8')).toBe(before); + }); });