diff --git a/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md index 4955d21..d5d92d3 100644 --- a/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md +++ b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md @@ -72,6 +72,36 @@ compatibility and canonical collision denial. - Independent security review: **APPROVE**, no verified authority/security findings on the final delta. +### Post-PR fail-closed remediation + +Independent rereview found resolver fail-open edges that the original green PR head did not cover. The +remediation remained uncommitted until every finding was reproduced red-first and the same reviewer +approved the complete two-file delta. + +Final regression evidence: + +```text +persona resolver: 47/47 +focused affected suites: 6 files / 138 tests +root-container resolver suites: 86/86 +full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests +``` + +DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves: + +- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths + fail closed across async, sync, listing, and status APIs; +- genuinely missing override directories still permit baseline fallback; +- cached missing scans are revalidated before fallback; +- marker-defined identity and domain metadata are revalidated on the second read; +- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas. + +Final independent finding-specific rereview: **APPROVE**. The reviewer verified exact absolute and +relative dangling-ancestor traversal, cached-missing revalidation, second-read marker identity, and +async/sync/list/status behavior. The source remained at remote head `1c41adad…` during remediation; +a fresh pushed head still requires terminal-green CI and durable exact-head reviewer-of-record before +merge. + ## Risks and boundaries diff --git a/packages/mosaic/src/commands/fleet-personas.spec.ts b/packages/mosaic/src/commands/fleet-personas.spec.ts index 37983d2..6fbed91 100644 --- a/packages/mosaic/src/commands/fleet-personas.spec.ts +++ b/packages/mosaic/src/commands/fleet-personas.spec.ts @@ -1,15 +1,17 @@ -import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { authorityForCanonicalClass, canonicalizeRoleClass, extractClassesFromDir, + extractClassesFromDirSync, listPersonaClasses, personaStatus, resolvePersona, + resolvePersonaFrom, resolvePersonaSync, } from './fleet-personas.js'; import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js'; @@ -174,12 +176,17 @@ describe('required FCM role library', () => { }); describe('extractClassesFromDir (shared extraction)', () => { - it('records class + domain from inline markers and degrades on missing dir', async () => { + it('records class + domain and distinguishes scanned from missing directories', async () => { const base = await extractClassesFromDir(rolesDir); + expect(base.scanState).toBe('scanned'); expect(base.classes.has('ceo')).toBe(true); expect(base.byClass.get('ceo')?.domain).toBe('executive'); - const missing = await extractClassesFromDir(join(tmp, 'nope')); + + const missingDir = join(tmp, 'nope'); + const missing = await extractClassesFromDir(missingDir); + expect(missing.scanState).toBe('missing'); expect(missing.classes.size).toBe(0); + expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing'); }); }); @@ -200,6 +207,229 @@ describe('resolvePersona — override wins', () => { expect(resolved?.content).toContain('BASELINE'); }); + it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'LIBRARY.md'), + '| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n', + 'utf8', + ); + + const resolved = await resolvePersona('code', { rolesDir, overrideDir }); + expect(resolved?.layer).toBe('baseline'); + expect(resolved?.content).toContain('BASELINE'); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not let an incidental later class marker shadow a readable baseline persona', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'mascot.md'), + '# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n', + 'utf8', + ); + + const resolved = await resolvePersona('code', { rolesDir, overrideDir }); + expect(resolved?.layer).toBe('baseline'); + expect(resolved?.content).toContain('BASELINE'); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not advertise an incidental-only class through listing or status APIs', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'mascot.md'), + '# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n', + 'utf8', + ); + + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'), + ).toBe(false); + }); + + it('fails closed if a marker-defined override becomes unreadable after extraction', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'engineering.md'); + await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await rm(overrideFile); + await mkdir(overrideFile); + + expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); + }); + + it('fails closed if a marker-defined override changes identity after extraction', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'engineering.md'); + await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); + + expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); + + const classes = await listPersonaClasses({ rolesDir, overrideDir }); + expect(classes.has('code')).toBe(true); + expect(classes.has('mascot')).toBe(true); + const status = new Map( + (await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]), + ); + expect(status.get('code')?.status).toBe('baseline'); + expect(status.get('mascot')?.status).toBe('custom'); + }); + + it('fails closed asynchronously when the override directory cannot be scanned', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed synchronously when the override directory cannot be scanned', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => { + await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => { + const danglingAncestor = join(tmp, 'dangling-ancestor'); + await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir'); + overrideDir = join(danglingAncestor, 'nested', 'roles.local'); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => { + const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor'); + await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir'); + overrideDir = `${danglingAncestor}/../roles.local`; + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => { + const danglingAncestor = join(tmp, 'relative-dangling-ancestor'); + await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir'); + overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`; + expect(overrideDir.startsWith('/')).toBe(false); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('revalidates a cached missing override before baseline fallback', async () => { + const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(cachedOverrideDir), + ]); + expect(over.scanState).toBe('missing'); + await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir'); + + expect( + await resolvePersonaFrom('code', { + rolesDir, + overrideDir: cachedOverrideDir, + base, + over, + }), + ).toBeNull(); + }); + + it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => { + const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir }); + const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir }); + expect(asyncResolution?.layer).toBe('baseline'); + expect(syncResolution?.layer).toBe('baseline'); + }); + + it('fails closed asynchronously when the canonical override exists but is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'), + ).toBe(false); + }); + + it('does not advertise baseline personas when the override directory is unscannable', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('does not advertise baseline personas through a dangling override-directory symlink', async () => { + await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); + + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('preserves baseline listings when the override directory is missing', async () => { + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not advertise an unreadable custom override as a valid persona class or status', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'ghost.md')); + + const extracted = await extractClassesFromDir(overrideDir); + expect(extracted.fileStems.has('ghost')).toBe(true); + expect(extracted.classes.has('ghost')).toBe(false); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'), + ).toBe(false); + }); + + it('fails closed synchronously when the canonical override exists but is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + it('returns null for an unknown class', async () => { expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull(); }); diff --git a/packages/mosaic/src/commands/fleet-personas.ts b/packages/mosaic/src/commands/fleet-personas.ts index 9da7dd7..2e83e5f 100644 --- a/packages/mosaic/src/commands/fleet-personas.ts +++ b/packages/mosaic/src/commands/fleet-personas.ts @@ -25,10 +25,10 @@ * can reference a customized or user-added persona. */ -import { readFileSync, readdirSync } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { lstat, readFile, readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, isAbsolute, join, sep } from 'node:path'; import type { Command } from 'commander'; function defaultMosaicHome(): string { @@ -53,8 +53,6 @@ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string { const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g; /** Optional `domain: Y` marker that travels alongside the class in the prose. */ const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/; -/** LIBRARY.md persona rows: the first table cell is the persona name. */ -const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm; /** Where a resolved persona's definition came from. */ export type PersonaLayer = 'baseline' | 'override'; @@ -131,41 +129,56 @@ export interface PersonaFile { klass: string; /** The markdown file the class was found in. */ file: string; + /** True when the first class marker, rather than filename, defined this mapping. */ + markerDefined?: boolean; domain?: string; } +export type DirScanState = 'scanned' | 'missing' | 'error'; + /** The set of persona classes a directory of role contracts defines. */ export interface DirClasses { - /** Every class name the dir contributes (markers + filenames + LIBRARY rows). */ + /** Whether the directory was scanned, absent, or present-but-unscannable. */ + scanState: DirScanState; + /** Every readable class name the directory contributes by filename or first marker. */ classes: Set; + /** Filename stems present on disk, retained even when a markdown entry is unreadable. */ + fileStems: Set; /** For classes whose file carries a marker, the file + domain that defined it. */ byClass: Map; } /** - * Scan one directory of role contracts and extract the persona classes it - * defines. THIS is the shared extraction both fleet-personas and fleet-profiles - * rely on. Sources, unioned (each needed — see module doc): - * 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline), - * 2. persona-name cells from LIBRARY.md index tables, - * 3. the role filename stem (covers marker-less alias docs like planner). + * Scan one directory of role contracts and extract readable persona identities. + * Valid identities come only from a successfully read non-LIBRARY filename and + * that file's first `class:` marker. LIBRARY rows and later prose mentions are + * index/reference data, not independently readable role contracts. * - * Missing dir / unreadable files degrade gracefully to whatever was found. - * `byClass` records the defining file+domain for marker-bearing classes so the - * resolver can map a class back to its file; filename-only and LIBRARY-only - * classes still appear in `classes` for membership checks. + * Missing directories are distinguished from present-but-unscannable paths. + * `byClass` records the first marker-defined file+domain so the resolver can map + * a class back to its contract; marker-less readable files map by filename. */ export async function extractClassesFromDir(dir: string): Promise { - const acc: DirClasses = { classes: new Set(), byClass: new Map() }; + const acc: DirClasses = { + scanState: 'scanned', + classes: new Set(), + fileStems: new Set(), + byClass: new Map(), + }; let entries: string[]; try { entries = await readdir(dir); - } catch { + } catch (error) { + acc.scanState = await classifyScanFailure(dir, error); return acc; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; + // Preserve entry presence separately from valid readable classes. Resolvers + // use it to fail closed on an explicit unreadable canonical override without + // advertising that override through class listing/status APIs. + if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md')); let text: string; try { text = await readFile(join(dir, entry), 'utf8'); @@ -184,16 +197,25 @@ export async function extractClassesFromDir(dir: string): Promise { * cannot await. Missing dir / unreadable files degrade gracefully. */ export function extractClassesFromDirSync(dir: string): DirClasses { - const acc: DirClasses = { classes: new Set(), byClass: new Map() }; + const acc: DirClasses = { + scanState: 'scanned', + classes: new Set(), + fileStems: new Set(), + byClass: new Map(), + }; let entries: string[]; try { entries = readdirSync(dir); - } catch { + } catch (error) { + acc.scanState = classifyScanFailureSync(dir, error); return acc; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; + // Keep sync extraction equivalent to the async scanner, including unreadable + // filename presence kept separate from valid readable classes. + if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md')); let text: string; try { text = readFileSync(join(dir, entry), 'utf8'); @@ -205,6 +227,83 @@ export function extractClassesFromDirSync(dir: string): DirClasses { return acc; } +async function classifyScanFailure(dir: string, error: unknown): Promise { + if (!isMissingPathError(error)) return 'error'; + return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing'; +} + +function classifyScanFailureSync(dir: string, error: unknown): DirScanState { + if (!isMissingPathError(error)) return 'error'; + return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing'; +} + +function traversalPrefixes(path: string): string[] { + const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`; + const parts = absolute.split(sep); + const prefixes: string[] = []; + let current: string = sep; + for (const part of parts) { + if (!part) continue; + current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`; + prefixes.push(current); + } + return prefixes; +} + +async function hasBrokenSymlinkInPath(path: string): Promise { + for (const current of traversalPrefixes(path)) { + try { + const entry = await lstat(current); + if (entry.isSymbolicLink()) { + try { + await stat(current); + } catch { + return true; + } + } + } catch (error) { + if (!isMissingPathError(error)) return true; + } + } + return false; +} + +function hasBrokenSymlinkInPathSync(path: string): boolean { + for (const current of traversalPrefixes(path)) { + try { + const entry = lstatSync(current); + if (entry.isSymbolicLink()) { + try { + statSync(current); + } catch { + return true; + } + } + } catch (error) { + if (!isMissingPathError(error)) return true; + } + } + return false; +} + +async function missingScanStillValid(dir: string): Promise { + try { + await readdir(dir); + return false; + } catch (error) { + return (await classifyScanFailure(dir, error)) === 'missing'; + } +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} + /** * Apply the class-extraction rules for ONE role file's text into `acc`. Pure * over already-read content, so the async and sync directory scanners share a @@ -213,32 +312,25 @@ export function extractClassesFromDirSync(dir: string): DirClasses { */ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void { const { classes, byClass } = acc; - if (entry === 'LIBRARY.md') { - for (const m of text.matchAll(LIBRARY_ROW)) { - const name = m[1]; - if (name && name !== 'persona') classes.add(name); - } - return; - } - // The filename stem is itself a valid class (covers marker-less alias docs). + if (entry === 'LIBRARY.md') return; + + // A successfully read role file is addressable by filename, including + // marker-less compatibility contracts such as planner.md. const stem = basename(entry, '.md'); classes.add(stem); const domainMatch = DOMAIN_MARKER.exec(text); const domain = domainMatch?.[1]; - let markedClassForFile: string | undefined; - for (const m of text.matchAll(CLASS_MARKER)) { - const klass = m[1]; - if (!klass) continue; - classes.add(klass); - // Record the FIRST marker as the file's defining class (the prose names - // the persona's own class up top; later mentions reference siblings). - if (!markedClassForFile) { - markedClassForFile = klass; - byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) }); - } - } - // A marker-less file still maps its stem to itself (no domain known). - if (!markedClassForFile && !byClass.has(stem)) { + const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined; + const markedClassForFile = firstMarker?.[1]; + if (markedClassForFile) { + classes.add(markedClassForFile); + byClass.set(markedClassForFile, { + klass: markedClassForFile, + file: join(dir, entry), + markerDefined: true, + ...(domain ? { domain } : {}), + }); + } else if (!byClass.has(stem)) { byClass.set(stem, { klass: stem, file: join(dir, entry) }); } } @@ -260,19 +352,13 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string } /** - * UNION of baseline classes and override classes. Overrides may ADD entirely new - * classes not present in the baseline, so callers (e.g. profile roster - * validation) treat a user-added persona as a real class. + * Every class with an actually readable winning contract. Discovery applies the + * same override shadow/fail-closed semantics as resolution, so callers never + * advertise a class that cannot be used. */ export async function listPersonaClasses(opts: PersonaDirs = {}): Promise> { - const { rolesDir, overrideDir } = resolveDirs(opts); - const [base, over] = await Promise.all([ - extractClassesFromDir(rolesDir), - extractClassesFromDir(overrideDir), - ]); - const union = new Set(base.classes); - for (const c of over.classes) union.add(c); - return union; + const { personas } = await collectUsablePersonas(opts); + return new Set(personas.keys()); } export type PersonaStatus = 'baseline' | 'overridden' | 'custom'; @@ -287,6 +373,110 @@ export interface PersonaResolution extends CanonicalRoleClass { domain?: string; } +async function readPersonaFromLayer( + requestedClass: string, + canonicalClass: string, + dir: string, + extracted: DirClasses, + layer: PersonaLayer, +): Promise { + const pf = extracted.byClass.get(canonicalClass); + if (!pf) { + if (!extracted.classes.has(canonicalClass)) return null; + const byName = join(dir, `${canonicalClass}.md`); + try { + const content = await readFile(byName, 'utf8'); + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: byName, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } + } + try { + const content = await readFile(pf.file, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (pf.markerDefined && currentMarker?.[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: pf.file, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } +} + +function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean { + return ( + over.scanState === 'error' || + over.fileStems.has(canonicalClass) || + over.byClass.has(canonicalClass) + ); +} + +function readPersonaFromLayerSync( + requestedClass: string, + canonicalClass: string, + dir: string, + extracted: DirClasses, + layer: PersonaLayer, +): PersonaResolution | null { + const pf = extracted.byClass.get(canonicalClass); + if (!pf) { + if (!extracted.classes.has(canonicalClass)) return null; + const byName = join(dir, `${canonicalClass}.md`); + try { + const content = readFileSync(byName, 'utf8'); + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: byName, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } + } + try { + const content = readFileSync(pf.file, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (pf.markerDefined && currentMarker?.[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: pf.file, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } +} + /** * Resolve a persona class to its winning definition: the override file if * roles.local/ defines that class, else the baseline. Match by inline `class:` @@ -320,53 +510,13 @@ export async function resolvePersonaFrom( canonicalizeRoleClass(requestedClass); const { rolesDir, overrideDir, base, over } = layers; - const fromLayer = async ( - dir: string, - extracted: DirClasses, - layer: PersonaLayer, - ): Promise => { - // Prefer the marker-defined file; fall back to the filename stem. - let pf = extracted.byClass.get(klass); - if (!pf) { - const byName = join(dir, `${klass}.md`); - if (!extracted.classes.has(klass)) return null; - // Class known only via filename/LIBRARY: read the stem file if present. - try { - const content = await readFile(byName, 'utf8'); - const dm = DOMAIN_MARKER.exec(content); - return { - requestedClass: requested, - canonicalClass: klass, - klass, - layer, - file: byName, - content, - ...(dm?.[1] ? { domain: dm[1] } : {}), - }; - } catch { - return null; - } - } - try { - const content = await readFile(pf.file, 'utf8'); - return { - requestedClass: requested, - canonicalClass: klass, - klass, - layer, - file: pf.file, - content, - ...(pf.domain ? { domain: pf.domain } : {}), - }; - } catch { - return null; - } - }; - - return ( - (await fromLayer(overrideDir, over, 'override')) ?? - (await fromLayer(rolesDir, base, 'baseline')) - ); + const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override'); + if (override) return override; + // An explicit canonical override that cannot be resolved/read must shadow the + // baseline fail-closed; only an absent override may fall back. + if (overrideShadowsBaseline(over, klass)) return null; + if (over.scanState === 'missing' && !(await missingScanStillValid(overrideDir))) return null; + return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline'); } /** @@ -387,49 +537,46 @@ export function resolvePersonaSync( const base = extractClassesFromDirSync(rolesDir); const over = extractClassesFromDirSync(overrideDir); - const fromLayer = ( - dir: string, - extracted: DirClasses, - layer: PersonaLayer, - ): PersonaResolution | null => { - // Prefer the marker-defined file; fall back to the filename stem. - const pf = extracted.byClass.get(klass); - if (!pf) { - if (!extracted.classes.has(klass)) return null; - const byName = join(dir, `${klass}.md`); - try { - const content = readFileSync(byName, 'utf8'); - const dm = DOMAIN_MARKER.exec(content); - return { - requestedClass: requested, - canonicalClass: klass, - klass, - layer, - file: byName, - content, - ...(dm?.[1] ? { domain: dm[1] } : {}), - }; - } catch { - return null; - } - } - try { - const content = readFileSync(pf.file, 'utf8'); - return { - requestedClass: requested, - canonicalClass: klass, - klass, - layer, - file: pf.file, - content, - ...(pf.domain ? { domain: pf.domain } : {}), - }; - } catch { - return null; - } - }; + const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override'); + if (override) return override; + if (overrideShadowsBaseline(over, klass)) return null; + return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline'); +} - return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline'); +interface UsablePersonaIndex { + personas: Map; + readableBaseline: Set; +} + +async function collectUsablePersonas(opts: PersonaDirs): Promise { + const { rolesDir, overrideDir } = resolveDirs(opts); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + if (over.scanState === 'error') { + return { personas: new Map(), readableBaseline: new Set() }; + } + + const candidates = new Set([...base.classes, ...over.classes]); + const checked = await Promise.all( + [...candidates].map(async (requestedClass) => { + const { canonicalClass } = canonicalizeRoleClass(requestedClass); + const [persona, baseline] = await Promise.all([ + resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }), + readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'), + ]); + return { requestedClass, persona, baseline }; + }), + ); + + const personas = new Map(); + const readableBaseline = new Set(); + for (const { requestedClass, persona, baseline } of checked) { + if (persona) personas.set(requestedClass, persona); + if (baseline) readableBaseline.add(requestedClass); + } + return { personas, readableBaseline }; } export interface PersonaStatusEntry { @@ -446,24 +593,20 @@ export interface PersonaStatusEntry { * Domain is taken from the WINNING layer (override domain wins if present). */ export async function personaStatus(opts: PersonaDirs = {}): Promise { - const { rolesDir, overrideDir } = resolveDirs(opts); - const [base, over] = await Promise.all([ - extractClassesFromDir(rolesDir), - extractClassesFromDir(overrideDir), - ]); - - const all = new Set([...base.classes, ...over.classes]); - const domainOf = (extracted: DirClasses, klass: string): string | undefined => - extracted.byClass.get(klass)?.domain; - - const entries: PersonaStatusEntry[] = []; - for (const klass of all) { - const inBase = base.classes.has(klass); - const inOver = over.classes.has(klass); - const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline'; - const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass); - entries.push({ klass, status, ...(domain ? { domain } : {}) }); - } + const { personas, readableBaseline } = await collectUsablePersonas(opts); + const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => { + const status: PersonaStatus = + persona.layer === 'baseline' + ? 'baseline' + : readableBaseline.has(klass) + ? 'overridden' + : 'custom'; + return { + klass, + status, + ...(persona.domain ? { domain: persona.domain } : {}), + }; + }); entries.sort((a, b) => a.klass.localeCompare(b.klass)); return entries; }