diff --git a/packages/oracle/PLACES.md b/packages/oracle/PLACES.md index 50b5078a..397a7255 100644 --- a/packages/oracle/PLACES.md +++ b/packages/oracle/PLACES.md @@ -53,6 +53,16 @@ than mixing facts from two generations of the program. This is the concrete mechanism behind "prevent mixed-generation snapshots": correspondence is checked, not assumed. +### Tag attribution (never an arbitrary winner) + +A JSX tag attributes to a component through S4's import facts. A bare binding +that matches two components never resolves to an arbitrary winner: a relative +import specifier names one file, so the path decides what the binding cannot, +and anything still ambiguous is surfaced through `unresolved(file)` with its +candidate ids — an addressable gap, not a silent drop. Tags outside the +universe stay unlisted: a plain wrapper is an opaque boundary, not a failed +attribution. + ## 2. The model ```text @@ -149,16 +159,105 @@ CLI-vs-host stylesheet parity and rendered-output asserts are unaffected. invocation, wrong snapshot → the correspondence guard must refuse it, not answer from the rollup manifest. -## 5. What we are explicitly not beginning with +## 5. Observations as evidence (charter step 5) + +An observation is what was actually seen for one rendered element — from the +DOM, an SSR payload, or a hand-copied class list. It is evidence, not proof: +observations narrow possibilities or discharge particular unknowns, and they +never manufacture certainty. + +```text +ObservedElement = optional tag + optional complete class list + optional + complete attribute map. An absent field is unobserved, + never empty. +Observation = subject element + ancestor chain (innermost first) + + completeToRoot + source (dom | ssr | classes). +``` + +Two entries consume observations: + +- **`locate(observation)`** — the observation-first entry. Components are + identified by emitted class name membership (exact `className`, so it works + where bare bindings collide and `resolveTarget` must refuse). Variant and + state bindings are _proposed_ from the observed class grammar and _verified_ + by replaying `TargetResolution.classes(point)` — a proposal that fails + replay is a conflict, never a binding. Every correspondence-checked place of + a matched component is scored: `consistent`, `conditional` (possible only if + a scoped refutation's beyond-file-root assumption fails — the note names the + scope), or `contradicted` (with the specific conflict). The answer is a + narrowing, never a pick. +- **`observe(place, observation)`** — evidence application. An open axis + discharges to established when a satisfying observed ancestor exists + (stateful axes never establish from a snapshot observation — structure + cannot witness `:hover`), or to refuted when a complete-to-root chain has no + satisfying element; the refutation is scoped to the observed chain, and an + incomplete chain discharges nothing. A statically _scoped_ refutation plus + an establishing observation rebinds — the observation discharges the + beyond-file-root assumption, not the model. A genuine contradiction (a + static established witness against a complete chain that lacks it) is + surfaced and discharges **nothing** — the observation-generation analogue of + the correspondence guard. Every discharge records its `evidence` source and + adds an assumption naming the observation, so observation authority stays + visible wherever the answer flows. + +An observed `data-color-mode` ancestor implies the `mode` coordinate, +validated against the snapshot's declared modes — an undeclared value is a +conflict, never a fabricated coordinate. Deferred, deliberately: `carry` +still partitions from static places only (observed places feed `explain`), +and observations carry no interaction state. + +## 6. Warm operation and cross-build uses (charter steps 5b–6) + +Warmth never outlives the truth. The cold path's honesty guarantees are +properties of a moment; a warm process lives while the working tree and the +artifacts change under it, so both guarantees are re-established per +question: + +- **Source, per question**: `structureOf` re-reads the file on every call and + keys its cache by content — an edit after load flips the answer to + `diverged`, and a revert restores it. Correspondence is a property of the + file as it is now, never of the session's first look at it. +- **Artifacts, per request**: `revalidate()` compares the loaded + `manifest.json` / `styles.css` / `commit.json` bytes against disk. A warm + consumer that finds them changed is holding a dead generation and must + refuse or reload — never keep answering. + +The **session** is the warm surface: `animus-oracle session` reads one JSON +request per stdin line and writes one response per stdout line (human +narration stays on stderr). Ops: `snapshot`, `check`, `files`, +`invocations`, `unresolved`, `at`, `place`, `explain`, `carry`, `locate`, +`observe`, `shutdown`. Every op but `snapshot` revalidates first and refuses +with `stale-snapshot` once the artifact set is rebuilt; file-scoped ops +surface correspondence refusals as `refused`, never a bare null. The process +boundary stays reversible: the session is a loop over the same library +surface, not a daemon. + +Two CI-facing uses ride the same guarantees: + +- **`check`** — the correspondence guard as a batch gate: every snapshot + file is verified against the working tree, the exit code is the verdict + (0 corresponding, 1 diverged), and the JSON report names each failing + file with its divergences. +- **`compareSnapshots(before, after)`** — cross-build identity at MVP depth: + generations relate by program hash, components by id, places by + (file, component, occurrence index) with per-axis binding drift reported + for persisted places. Files refused on either side produce **no** place + claims — they are listed as refusals instead. Deliberately not complete + cross-build identity: a moved invocation reads as removed + added, and a + renamed file breaks the thread. + +## 7. What we are explicitly not beginning with No browser/layout engine, no React tree simulator, no exhaustive context enumeration, no globally installed daemon, no separate Rust service, no universal geometry reasoning, no mandatory runtime instrumentation, no complete -cross-build identity. One-shot execution first; a warm workspace process only -after the answers are valuable; cross-build and CI uses only once snapshot -correspondence is credible. +cross-build identity. The charter's gates have been passed in order: one-shot +execution first, then the warm session once the answers proved valuable, then +the `check` gate and snapshot comparison once correspondence was credible +(§6) — but the warm surface remains a loop over the library, not a daemon, +and cross-build identity remains occurrence-deep, not complete. -## 6. Continue / narrow / pivot +## 8. Continue / narrow / pivot - **Continue broadly** if real invocations usually resolve to small, understandable place sets; answers agree with Animus and selected browser diff --git a/packages/oracle/__tests__/places-compare.test.ts b/packages/oracle/__tests__/places-compare.test.ts new file mode 100644 index 00000000..40084d1f --- /dev/null +++ b/packages/oracle/__tests__/places-compare.test.ts @@ -0,0 +1,105 @@ +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { compareSnapshots, loadSnapshot } from '../src/places'; + +/** + * PLACES.md §6 — cross-build identity at MVP depth. Two snapshots relate by + * program hash, component id, and place occurrence; refused files produce no + * place claims at all. + */ + +const FIXTURE = join(__dirname, 'fixtures/rollup-app'); +const SOURCE_ROOT = join(__dirname, '../../../e2e/rollup-app'); +const GROUP_FILE = 'src/Group.tsx'; +const GROUP_ITEM_ID = + '../../packages/test-ds/src/components/GroupItem.tsx::GroupItem'; + +describe('compareSnapshots', () => { + it('reports one generation as identical, every place persisted', () => { + const before = loadSnapshot(FIXTURE, { sourceRoot: SOURCE_ROOT }); + const after = loadSnapshot(FIXTURE, { sourceRoot: SOURCE_ROOT }); + const comparison = compareSnapshots(before, after); + + expect(comparison.identical).toBe(true); + expect(comparison.components.added).toEqual([]); + expect(comparison.components.removed).toEqual([]); + expect(comparison.refusals).toEqual([]); + expect(comparison.places.length).toBeGreaterThan(0); + expect( + comparison.places.every((place) => place.status === 'persisted') + ).toBe(true); + expect( + comparison.places.every((place) => place.bindingChanges === undefined) + ).toBe(true); + + const groupPlaces = comparison.places.filter( + (place) => place.component === GROUP_ITEM_ID + ); + expect(groupPlaces).toHaveLength(4); + expect(new Set(groupPlaces.map((place) => place.file))).toEqual( + new Set([GROUP_FILE]) + ); + }); + + it('classifies places of a dropped file as removed, not silently gone', () => { + const dir = mkdtempSync(join(tmpdir(), 'places-compare-')); + cpSync(FIXTURE, dir, { recursive: true }); + const manifestPath = join(dir, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + fileFacts: Record; + }; + delete manifest.fileFacts[GROUP_FILE]; + writeFileSync(manifestPath, JSON.stringify(manifest)); + + const before = loadSnapshot(FIXTURE, { sourceRoot: SOURCE_ROOT }); + const after = loadSnapshot(dir, { sourceRoot: SOURCE_ROOT }); + const comparison = compareSnapshots(before, after); + + expect(comparison.identical).toBe(false); + // The component definition survives — only its invocation places went. + expect(comparison.components.removed).toEqual([]); + const groupPlaces = comparison.places.filter( + (place) => place.component === GROUP_ITEM_ID + ); + expect(groupPlaces).toHaveLength(4); + expect(groupPlaces.every((place) => place.status === 'removed')).toBe(true); + }); + + it('makes no place claims about a file refused on either side', () => { + const root = mkdtempSync(join(tmpdir(), 'places-compare-drift-')); + mkdirSync(join(root, 'src'), { recursive: true }); + const source = readFileSync(join(SOURCE_ROOT, GROUP_FILE), 'utf8'); + writeFileSync( + join(root, GROUP_FILE), + source.replace( + '
', + '
' + ) + ); + + const before = loadSnapshot(FIXTURE, { sourceRoot: SOURCE_ROOT }); + const after = loadSnapshot(FIXTURE, { sourceRoot: root }); + const comparison = compareSnapshots(before, after); + + expect(comparison.refusals).toContainEqual( + expect.objectContaining({ + side: 'after', + file: GROUP_FILE, + reason: 'diverged', + }) + ); + // Neither removed nor added nor persisted — the refusal IS the answer. + expect( + comparison.places.filter((place) => place.file === GROUP_FILE) + ).toEqual([]); + }); +}); diff --git a/packages/oracle/__tests__/places-observation.test.ts b/packages/oracle/__tests__/places-observation.test.ts new file mode 100644 index 00000000..c629f5df --- /dev/null +++ b/packages/oracle/__tests__/places-observation.test.ts @@ -0,0 +1,382 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { createPlaceAnalysis, loadSnapshot } from '../src/places'; + +import type { Observation, PlaceAnalysis, Snapshot } from '../src/places'; + +/** + * PLACES.md §5 — observations as evidence. The observation-first entry + * (`locate`) narrows the candidate places an observed render could have come + * from, and `observe` discharges a place's open axes from an observed + * ancestor chain — with observation authority recorded, refutation gated on + * chain completeness, and contradictions surfaced instead of averaged in. + */ + +const FIXTURE = join(__dirname, 'fixtures/rollup-app'); +const SOURCE_ROOT = join(__dirname, '../../../e2e/rollup-app'); +const GROUP_FILE = 'src/Group.tsx'; + +const GROUP_ITEM_CLASS = 'animus-GroupItem-32b2d32f'; +const KIT_BUTTON_ID = + '../../packages/test-ds/src/components/Button.tsx::Button'; +const KIT_BUTTON_CLASS = 'animus-Button-c63b6dcd'; +const KIT_BUTTON_VARIANT_DIMENSION = `variant:${KIT_BUTTON_ID}:variant`; + +const ACTIVE_AXIS = 'ancestor:[data-active=true]'; +const HOVER_AXIS = 'ancestor:.group:hover'; + +const snapshot: Snapshot = loadSnapshot(FIXTURE, { sourceRoot: SOURCE_ROOT }); +const analysis: PlaceAnalysis = createPlaceAnalysis(snapshot); + +const sourceText = readFileSync(join(SOURCE_ROOT, GROUP_FILE), 'utf8'); + +const invocationAt = (marker: string) => { + const offset = sourceText.indexOf(marker); + expect(offset).toBeGreaterThan(0); + const invocation = analysis.at(GROUP_FILE, offset); + expect(invocation).toBeDefined(); + if (invocation === undefined) throw new Error('unreachable'); + return invocation; +}; + +const candidateAt = ( + result: ReturnType, + marker: string +) => { + const invocation = invocationAt(marker); + const match = result.matches.find( + (entry) => entry.component.className === GROUP_ITEM_CLASS + ); + expect(match).toBeDefined(); + const candidate = match?.candidates.find( + (entry) => + entry.place.invocation.file === invocation.file && + entry.place.invocation.ordinal === invocation.ordinal + ); + expect(candidate).toBeDefined(); + if (candidate === undefined) throw new Error('unreachable'); + return candidate; +}; + +describe('locate — the observation-first entry (PLACES.md §5)', () => { + it('narrows the candidate places from an observed active wrapper', () => { + const result = analysis.locate({ + source: 'dom', + subject: { classes: ['app-shell', GROUP_ITEM_CLASS] }, + ancestors: [ + { + tag: 'div', + classes: ['group'], + attributes: { 'data-active': 'true' }, + }, + ], + }); + + // The observed wrapper is compatible with the established place, only + // conditionally compatible with the refuted one (its refutation is + // scoped to in-file structure), and undecidable at the open places. + expect(candidateAt(result, 'active kit item').verdict).toBe('consistent'); + const inactive = candidateAt(result, 'inactive kit item'); + expect(inactive.verdict).toBe('conditional'); + expect(inactive.notes.join('\n')).toMatch(/JSX root|scoped/); + expect(candidateAt(result, 'framed kit item').verdict).toBe('consistent'); + // The dynamic-wrapper place is conditional too — but only through the + // hover axis's structural half (the observed chain carries `group`, + // which that place refutes in-file), never through data-active, which + // stays open there. + const dynamic = candidateAt(result, 'conditional kit item'); + expect(dynamic.verdict).toBe('conditional'); + expect(dynamic.notes.join('\n')).toMatch(/group:hover/); + expect(dynamic.notes.join('\n')).not.toMatch(/data-active/); + + expect(result.unmatchedClasses).toEqual(['app-shell']); + }); + + it('contradicts the established place when a complete chain lacks it', () => { + const result = analysis.locate({ + source: 'dom', + subject: { classes: [GROUP_ITEM_CLASS] }, + ancestors: [ + { tag: 'div', classes: [], attributes: {} }, + { tag: 'body', classes: [], attributes: {} }, + { tag: 'html', classes: [], attributes: {} }, + ], + completeToRoot: true, + }); + + const active = candidateAt(result, 'active kit item'); + expect(active.verdict).toBe('contradicted'); + expect(active.notes.join('\n')).toMatch(/data-active/); + expect(candidateAt(result, 'inactive kit item').verdict).toBe('consistent'); + }); + + it('reports meaningless classes honestly: no match, nothing invented', () => { + const result = analysis.locate({ + source: 'classes', + subject: { classes: ['not-an-animus-class'] }, + }); + expect(result.matches).toEqual([]); + expect(result.unmatchedClasses).toEqual(['not-an-animus-class']); + }); + + it('identifies a component by emitted class where bindings collide', () => { + // Two `Button` bindings exist in this snapshot, so bare-name resolution + // must refuse — but the observed class names exactly one of them. + expect(snapshot.host.identity.resolveTarget('Button')).toBeUndefined(); + + const result = analysis.locate({ + source: 'dom', + subject: { + classes: [KIT_BUTTON_CLASS, `${KIT_BUTTON_CLASS}--variant-primary`], + }, + }); + + const match = result.matches.find( + (entry) => entry.component.id === KIT_BUTTON_ID + ); + expect(match).toBeDefined(); + // The variant binding is proposed from the class grammar and verified by + // replaying `classes(point)` — the implied point survives replay. + expect(match?.impliedPoint).toMatchObject({ + [KIT_BUTTON_VARIANT_DIMENSION]: 'primary', + }); + expect(match?.conflicts).toEqual([]); + }); + + it('turns an impossible class pair into a conflict, not a binding', () => { + const result = analysis.locate({ + source: 'classes', + subject: { + classes: [ + KIT_BUTTON_CLASS, + `${KIT_BUTTON_CLASS}--variant-primary`, + `${KIT_BUTTON_CLASS}--variant-ghost`, + ], + }, + }); + + const match = result.matches.find( + (entry) => entry.component.id === KIT_BUTTON_ID + ); + expect(match).toBeDefined(); + expect(match?.conflicts.length).toBeGreaterThan(0); + expect(match?.impliedPoint).not.toHaveProperty( + KIT_BUTTON_VARIANT_DIMENSION + ); + }); + + it('implies the mode coordinate from an observed data-color-mode root', () => { + const result = analysis.locate({ + source: 'dom', + subject: { classes: [GROUP_ITEM_CLASS] }, + ancestors: [ + { tag: 'html', classes: [], attributes: { 'data-color-mode': 'dark' } }, + ], + }); + const match = result.matches.find( + (entry) => entry.component.className === GROUP_ITEM_CLASS + ); + expect(match?.impliedPoint).toMatchObject({ mode: 'dark' }); + }); + + it('rejects an undeclared mode value instead of fabricating one', () => { + const result = analysis.locate({ + source: 'dom', + subject: { classes: [GROUP_ITEM_CLASS] }, + ancestors: [ + { + tag: 'html', + classes: [], + attributes: { 'data-color-mode': 'sepia' }, + }, + ], + }); + const match = result.matches.find( + (entry) => entry.component.className === GROUP_ITEM_CLASS + ); + expect(match?.impliedPoint).not.toHaveProperty('mode'); + expect(match?.conflicts.join('\n')).toMatch(/sepia/); + }); +}); + +describe('observe — discharging open axes with evidence (PLACES.md §5)', () => { + const framedPlace = () => analysis.placeOf(invocationAt('framed kit item')); + + it('establishes the hidden axis behind the opaque Frame from an SSR chain', () => { + const place = framedPlace(); + expect(place.bindings).toContainEqual( + expect.objectContaining({ + axis: ACTIVE_AXIS, + state: 'open', + reason: 'opaque-component', + }) + ); + + const observation: Observation = { + source: 'ssr', + ancestors: [ + { + tag: 'section', + classes: ['frame'], + attributes: { 'data-active': 'true' }, + }, + { tag: 'div', classes: [], attributes: {} }, + ], + completeToRoot: true, + }; + const observed = analysis.observe(place, observation); + + expect(observed.contradictions).toEqual([]); + expect(observed.place.bindings).toContainEqual( + expect.objectContaining({ + axis: ACTIVE_AXIS, + state: 'established', + evidence: expect.objectContaining({ source: 'ssr' }), + }) + ); + // The chain is complete and carries no `.group`, so the stateful axis is + // refuted — hover can never fire without its structural half. + expect(observed.place.bindings).toContainEqual( + expect.objectContaining({ + axis: HOVER_AXIS, + state: 'refuted', + evidence: expect.objectContaining({ source: 'ssr' }), + }) + ); + expect(observed.discharged.map((binding) => binding.axis).sort()).toEqual([ + HOVER_AXIS, + ACTIVE_AXIS, + ]); + expect(observed.place.assumptions.join('\n')).toMatch(/observ/i); + + // The discharged place now answers what the opaque place could not: + // at light mode the active rule wins — before the observation the axis + // was unbound and the base color rule won. + const before = analysis.explain(place, { + property: 'color', + at: { mode: 'light' }, + }); + expect(before.winner?.selector).toBe(`.${GROUP_ITEM_CLASS}`); + const after = analysis.explain(observed.place, { + property: 'color', + at: { mode: 'light' }, + }); + expect(after.winner?.selector).toBe( + `[data-active="true"] .${GROUP_ITEM_CLASS}` + ); + }); + + it('refutes only from a complete chain; an incomplete one stays open', () => { + // The real Frame render: section.frame with no data-active anywhere. + const chain = [ + { tag: 'section', classes: ['frame'], attributes: {} }, + { tag: 'div', classes: [], attributes: {} }, + ]; + + const incomplete = analysis.observe(framedPlace(), { + source: 'ssr', + ancestors: chain, + }); + expect(incomplete.place.bindings).toContainEqual( + expect.objectContaining({ axis: ACTIVE_AXIS, state: 'open' }) + ); + expect(incomplete.discharged).toEqual([]); + + const complete = analysis.observe(framedPlace(), { + source: 'ssr', + ancestors: chain, + completeToRoot: true, + }); + expect(complete.place.bindings).toContainEqual( + expect.objectContaining({ + axis: ACTIVE_AXIS, + state: 'refuted', + evidence: expect.objectContaining({ source: 'ssr' }), + }) + ); + expect(complete.place.assumptions.join('\n')).toMatch(/observed chain/); + }); + + it('surfaces a contradiction and discharges nothing — never averages', () => { + // The active place statically establishes the axis with an in-file + // witness. A complete observed chain without it cannot be a render of + // this place — the whole observation is suspect, so even the hover + // axis it could have refuted stays untouched. + const place = analysis.placeOf(invocationAt('active kit item')); + const observed = analysis.observe(place, { + source: 'dom', + ancestors: [ + { tag: 'div', classes: [], attributes: {} }, + { tag: 'body', classes: [], attributes: {} }, + ], + completeToRoot: true, + }); + + expect(observed.contradictions.length).toBeGreaterThan(0); + expect(observed.contradictions.join('\n')).toMatch(/data-active/); + expect(observed.discharged).toEqual([]); + expect(observed.place.bindings).toEqual(place.bindings); + }); + + it('lets an observation discharge a scoped refutation beyond the file', () => { + // The inactive place refutes the axis, scoped to in-file structure. An + // observed wrapper beyond the JSX root discharges the assumption, not + // the model: the axis rebinds to established with observation evidence. + const place = analysis.placeOf(invocationAt('inactive kit item')); + const observed = analysis.observe(place, { + source: 'dom', + ancestors: [ + { tag: 'div', classes: [], attributes: { 'data-active': 'false' } }, + { tag: 'main', classes: [], attributes: { 'data-active': 'true' } }, + ], + completeToRoot: true, + }); + + expect(observed.contradictions).toEqual([]); + expect(observed.place.bindings).toContainEqual( + expect.objectContaining({ + axis: ACTIVE_AXIS, + state: 'established', + evidence: expect.objectContaining({ source: 'dom' }), + }) + ); + expect(observed.place.assumptions.join('\n')).toMatch(/assumption/); + }); + + it('never establishes a stateful axis from a snapshot observation', () => { + // A `.group` wrapper observed in the chain satisfies the structural + // half of `.group:hover` — but a DOM snapshot cannot witness hover, so + // the axis stays open rather than silently activating hover styling. + const observed = analysis.observe(framedPlace(), { + source: 'dom', + ancestors: [ + { tag: 'section', classes: ['group', 'frame'], attributes: {} }, + ], + completeToRoot: true, + }); + expect(observed.place.bindings).toContainEqual( + expect.objectContaining({ + axis: HOVER_AXIS, + state: 'open', + reason: 'stateful-pseudo', + }) + ); + }); + + it('leaves an axis open when the observation cannot see enough', () => { + // An observed element with no attribute map is partial knowledge: it can + // neither satisfy nor exclude the requirement, and with the chain + // incomplete nothing discharges. + const observed = analysis.observe(framedPlace(), { + source: 'dom', + ancestors: [{ tag: 'section' }], + completeToRoot: true, + }); + expect(observed.place.bindings).toContainEqual( + expect.objectContaining({ axis: ACTIVE_AXIS, state: 'open' }) + ); + expect(observed.discharged).toEqual([]); + }); +}); diff --git a/packages/oracle/__tests__/places-resolve.test.ts b/packages/oracle/__tests__/places-resolve.test.ts new file mode 100644 index 00000000..154c4dda --- /dev/null +++ b/packages/oracle/__tests__/places-resolve.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { createPlaceAnalysis, loadSnapshot } from '../src/places'; +import { resolveComponentTag } from '../src/places/resolve'; + +import type { ComponentRecord } from '../src/providers/identity'; + +/** + * Attributing a JSX tag to an extracted component (PLACES.md §1, seam S4). + * A bare binding that matches two components must never resolve to an + * arbitrary winner — but a relative import specifier names one file, so the + * path can decide what the binding cannot. What remains ambiguous is + * surfaced through `PlaceAnalysis.unresolved`, never silently dropped. + */ + +const record = ( + id: string, + file: string, + binding: string +): ComponentRecord => ({ + id, + file, + binding, + className: `animus-${binding}-${id.length.toString(16)}`, + terminal: 'asElement', +}); + +const LOCAL_BUTTON = record( + 'src/Button.tsx::Button', + 'src/Button.tsx', + 'Button' +); +const KIT_BUTTON = record( + '../../packages/test-ds/src/components/Button.tsx::Button', + '../../packages/test-ds/src/components/Button.tsx', + 'Button' +); +const COMPONENTS = [LOCAL_BUTTON, KIT_BUTTON]; + +describe('resolveComponentTag', () => { + it('resolves a colliding binding through its relative import path', () => { + const resolution = resolveComponentTag( + COMPONENTS, + [{ local: 'Button', imported: 'Button', source: './Button' }], + 'src/entry.tsx', + 'Button' + ); + expect(resolution).toEqual({ kind: 'resolved', component: LOCAL_BUTTON }); + }); + + it('resolves ../ specifiers against the importing file, not the root', () => { + const resolution = resolveComponentTag( + COMPONENTS, + [{ local: 'Button', imported: 'Button', source: '../Button' }], + 'src/nested/entry.tsx', + 'Button' + ); + expect(resolution).toEqual({ kind: 'resolved', component: LOCAL_BUTTON }); + }); + + it('completes an index file behind a directory specifier', () => { + const indexed = record( + 'src/Button/index.tsx::Button', + 'src/Button/index.tsx', + 'Button' + ); + const resolution = resolveComponentTag( + [indexed, KIT_BUTTON], + [{ local: 'Button', imported: 'Button', source: './Button' }], + 'src/entry.tsx', + 'Button' + ); + expect(resolution).toEqual({ kind: 'resolved', component: indexed }); + }); + + it('surfaces a package-specifier collision as ambiguous, never a winner', () => { + const resolution = resolveComponentTag( + COMPONENTS, + [{ local: 'Button', imported: 'Button', source: '@animus-ui/test-ds' }], + 'src/entry.tsx', + 'Button' + ); + expect(resolution).toMatchObject({ + kind: 'ambiguous', + specifier: '@animus-ui/test-ds', + }); + if (resolution.kind === 'ambiguous') { + expect(resolution.candidates).toHaveLength(2); + } + }); + + it('surfaces a bare colliding binding with no import fact as ambiguous', () => { + const resolution = resolveComponentTag( + COMPONENTS, + undefined, + 'src/entry.tsx', + 'Button' + ); + expect(resolution).toMatchObject({ kind: 'ambiguous' }); + }); + + it('reports a tag outside the universe as unknown, not ambiguous', () => { + const resolution = resolveComponentTag( + COMPONENTS, + [{ local: 'Frame', imported: 'Frame', source: './Frame' }], + 'src/entry.tsx', + 'Frame' + ); + expect(resolution).toEqual({ kind: 'unknown' }); + }); + + it('lets a same-file binding win before any import is consulted', () => { + const resolution = resolveComponentTag( + COMPONENTS, + [{ local: 'Button', imported: 'Button', source: '@animus-ui/test-ds' }], + 'src/Button.tsx', + 'Button' + ); + expect(resolution).toEqual({ kind: 'resolved', component: LOCAL_BUTTON }); + }); +}); + +describe('unresolved invocations surface on the analysis', () => { + const FIXTURE = join(__dirname, 'fixtures/rollup-app'); + const SOURCE_ROOT = join(__dirname, '../../../e2e/rollup-app'); + const snapshot = loadSnapshot(FIXTURE, { sourceRoot: SOURCE_ROOT }); + const analysis = createPlaceAnalysis(snapshot); + + it('reports nothing for a file whose tags all attribute cleanly', () => { + expect(analysis.unresolved('src/Group.tsx')).toEqual([]); + expect(analysis.invocationsOf('GroupItem')).toHaveLength(4); + }); + + it('reports nothing for a file outside the snapshot', () => { + expect(analysis.unresolved('src/App.tsx')).toEqual([]); + }); + + it('keeps unresolved spans addressable in the real source', () => { + // Every unresolved entry, when one exists, must carry a span that + // `at(file, offset)` semantics could point into — pin the contract on + // the shape even while the fixture holds no ambiguity. + const source = readFileSync(join(SOURCE_ROOT, 'src/Group.tsx'), 'utf8'); + expect(source.length).toBeGreaterThan(0); + for (const entry of analysis.unresolved('src/Group.tsx')) { + expect(entry.span[0]).toBeGreaterThanOrEqual(0); + expect(entry.span[1]).toBeLessThanOrEqual(source.length); + } + }); +}); diff --git a/packages/oracle/__tests__/places-session.test.ts b/packages/oracle/__tests__/places-session.test.ts new file mode 100644 index 00000000..eeaf73ae --- /dev/null +++ b/packages/oracle/__tests__/places-session.test.ts @@ -0,0 +1,266 @@ +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +import { runCli } from '../src/cli/run'; +import { createPlacesSession, runSession } from '../src/cli/session'; + +import type { SessionResponse } from '../src/cli/session'; + +/** + * PLACES.md §6 — warm operation and the CI gate. One loaded snapshot answers + * many JSONL requests; artifact staleness turns every answer into an + * explicit refusal; and `check` runs the correspondence guard over every + * file as a batch gate with the exit code as the verdict. + */ + +const FIXTURE = join(__dirname, 'fixtures/rollup-app'); +const SOURCE_ROOT = join(__dirname, '../../../e2e/rollup-app'); +const GROUP_FILE = 'src/Group.tsx'; +const GROUP_ITEM_CLASS = 'animus-GroupItem-32b2d32f'; + +const groupSource = readFileSync(join(SOURCE_ROOT, GROUP_FILE), 'utf8'); + +const offsetOf = (marker: string): number => { + const offset = groupSource.indexOf(marker); + expect(offset).toBeGreaterThan(0); + return offset; +}; + +const okResult = (response: SessionResponse): unknown => { + expect(response.ok).toBe(true); + if (!response.ok) throw new Error('unreachable'); + return response.result; +}; + +describe('the warm session protocol', () => { + const session = createPlacesSession(FIXTURE, { sourceRoot: SOURCE_ROOT }); + + it('describes its snapshot: generation, size, freshness', () => { + const result = okResult( + session.handle({ id: 1, op: 'snapshot' }).response + ) as Record; + expect(result.generation).toMatch(/^animus-commit:/); + expect(result.files).toBeGreaterThan(0); + expect(result.freshness).toEqual({ fresh: true }); + }); + + it('answers place questions warm, straight from file+offset', () => { + const response = session.handle({ + id: 2, + op: 'explain', + file: GROUP_FILE, + offset: offsetOf('active kit item'), + property: 'color', + at: { mode: 'dark' }, + }).response; + const result = okResult(response) as { + winner?: { selector: string }; + }; + expect(result.winner?.selector).toBe( + `[data-color-mode="dark"] .${GROUP_ITEM_CLASS}` + ); + }); + + it('serves locate and observe over the wire shape', () => { + const located = okResult( + session.handle({ + op: 'locate', + observation: { + source: 'dom', + subject: { classes: [GROUP_ITEM_CLASS] }, + }, + }).response + ) as { matches: readonly { candidates: readonly unknown[] }[] }; + expect(located.matches).toHaveLength(1); + expect(located.matches[0].candidates).toHaveLength(4); + + const observed = okResult( + session.handle({ + op: 'observe', + file: GROUP_FILE, + offset: offsetOf('framed kit item'), + observation: { + source: 'ssr', + ancestors: [ + { + tag: 'section', + classes: ['frame'], + attributes: { 'data-active': 'true' }, + }, + ], + completeToRoot: true, + }, + }).response + ) as { discharged: readonly { axis: string }[] }; + expect(observed.discharged.length).toBeGreaterThan(0); + }); + + it('surfaces a correspondence refusal, never a bare null', () => { + const response = session.handle({ + op: 'place', + file: 'src/App.tsx', + offset: 10, + }).response; + expect(response).toMatchObject({ ok: false, kind: 'refused' }); + if (!response.ok) expect(response.error).toMatch(/not part of/); + }); + + it('rejects an unknown op with the supported list', () => { + const response = session.handle({ op: 'transmogrify' }).response; + expect(response).toMatchObject({ ok: false, kind: 'usage' }); + if (!response.ok) expect(response.error).toMatch(/locate/); + }); + + it('rejects a non-JSON line without dying', () => { + const outcome = session.handleLine('{nope'); + expect(outcome.response).toMatchObject({ ok: false, kind: 'usage' }); + expect(outcome.close).toBe(false); + }); + + it('carries a candidate repair through the warm surface', () => { + const darkRule = session.snapshot.host.universe + .universe() + .rules.find( + (rule) => + rule.selector.raw === `[data-color-mode="dark"] .${GROUP_ITEM_CLASS}` + ); + expect(darkRule).toBeDefined(); + const outcomes = okResult( + session.handle({ + op: 'carry', + component: 'GroupItem', + property: 'color', + deltas: [ + { + kind: 'remove-declaration', + rule: darkRule?.id, + property: 'color', + }, + ], + }).response + ) as readonly { outcome: string }[]; + expect(new Set(outcomes.map((row) => row.outcome))).toEqual( + new Set(['changed', 'stable', 'ambiguous', 'inaccessible']) + ); + }); +}); + +describe('staleness ends the warmth, explicitly', () => { + const scratch = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'places-session-')); + cpSync(FIXTURE, dir, { recursive: true }); + return dir; + }; + + it('refuses every op but snapshot once the artifacts change', () => { + const dir = scratch(); + const session = createPlacesSession(dir, { sourceRoot: SOURCE_ROOT }); + expect(session.handle({ op: 'files' }).response.ok).toBe(true); + + const manifestPath = join(dir, 'manifest.json'); + writeFileSync(manifestPath, `${readFileSync(manifestPath, 'utf8')}\n`); + + const refused = session.handle({ op: 'files' }).response; + expect(refused).toMatchObject({ ok: false, kind: 'stale-snapshot' }); + if (!refused.ok) expect(refused.changed).toContain('manifest.json'); + + // `snapshot` still answers — it is how the client learns to restart. + const described = okResult(session.handle({ op: 'snapshot' }).response) as { + freshness: { fresh: boolean }; + }; + expect(described.freshness.fresh).toBe(false); + }); +}); + +describe('the JSONL stream loop', () => { + it('answers each line and closes on shutdown', async () => { + const stdin = new PassThrough(); + const out: string[] = []; + const errs: string[] = []; + stdin.write(`${JSON.stringify({ id: 'a', op: 'snapshot' })}\n`); + stdin.write('\n'); + stdin.write(`${JSON.stringify({ id: 'b', op: 'shutdown' })}\n`); + stdin.end(); + + const code = await runSession( + FIXTURE, + { sourceRoot: SOURCE_ROOT }, + { + stdin, + stdout: { write: (text: string) => out.push(text) }, + stderr: { write: (text: string) => errs.push(text) }, + } + ); + + expect(code).toBe(0); + const responses = out.map( + (line) => JSON.parse(line) as { id?: string; ok: boolean } + ); + expect(responses).toHaveLength(2); + expect(responses[0]).toMatchObject({ id: 'a', ok: true }); + expect(responses[1]).toMatchObject({ id: 'b', ok: true }); + expect(errs.join('')).toMatch(/session open/); + }); +}); + +describe('check — the correspondence guard as a CI gate', () => { + it('exits 0 with a fully corresponding tree', async () => { + const out: string[] = []; + const code = await runCli( + ['check', '--dir', FIXTURE, '--source-root', SOURCE_ROOT, '--json'], + { + stdout: { write: (text: string) => out.push(text) }, + stderr: { write: () => undefined }, + } + ); + expect(code).toBe(0); + const envelope = JSON.parse(out.join('')) as { + command: string; + result: { ok: boolean; files: readonly { ok: boolean }[] }; + }; + expect(envelope.command).toBe('check'); + expect(envelope.result.ok).toBe(true); + expect(envelope.result.files.length).toBeGreaterThan(0); + }); + + it('exits 1 naming the diverged file when the tree drifts', async () => { + const root = mkdtempSync(join(tmpdir(), 'places-check-')); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync( + join(root, GROUP_FILE), + groupSource.replace( + '
', + '
' + ) + ); + + const out: string[] = []; + const errs: string[] = []; + const code = await runCli( + ['check', '--dir', FIXTURE, '--source-root', root, '--json'], + { + stdout: { write: (text: string) => out.push(text) }, + stderr: { write: (text: string) => errs.push(text) }, + } + ); + expect(code).toBe(1); + const envelope = JSON.parse(out.join('')) as { + result: { + ok: boolean; + files: readonly { file: string; ok: boolean; reason?: string }[]; + }; + }; + expect(envelope.result.ok).toBe(false); + const failing = envelope.result.files.filter((entry) => !entry.ok); + expect(failing.map((entry) => entry.file)).toContain(GROUP_FILE); + }); +}); diff --git a/packages/oracle/__tests__/places-warm.test.ts b/packages/oracle/__tests__/places-warm.test.ts new file mode 100644 index 00000000..0c370ab6 --- /dev/null +++ b/packages/oracle/__tests__/places-warm.test.ts @@ -0,0 +1,113 @@ +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { loadSnapshot } from '../src/places'; + +/** + * PLACES.md §6 — warm operation. A warm process lives while the working tree + * and the artifacts change under it, so the cold path's one-shot honesty has + * to hold over time: `structureOf` answers about the file as it is NOW + * (correspondence re-checked when content changes), and `revalidate` detects + * a rebuilt artifact set instead of letting a warm session keep answering + * from a dead generation. + */ + +const FIXTURE = join(__dirname, 'fixtures/rollup-app'); +const SOURCE_ROOT = join(__dirname, '../../../e2e/rollup-app'); +const GROUP_FILE = 'src/Group.tsx'; + +const groupSource = readFileSync(join(SOURCE_ROOT, GROUP_FILE), 'utf8'); + +const scratchSourceRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), 'places-warm-src-')); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, GROUP_FILE), groupSource); + return root; +}; + +const scratchArtifacts = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'places-warm-art-')); + cpSync(FIXTURE, dir, { recursive: true }); + return dir; +}; + +describe('structureOf stays correspondence-checked over time', () => { + it('flips ok → diverged when the source drifts after load', () => { + const root = scratchSourceRoot(); + const snapshot = loadSnapshot(FIXTURE, { sourceRoot: root }); + + expect(snapshot.structureOf(GROUP_FILE).ok).toBe(true); + + // The same drift edit the cold guard catches — but applied AFTER the + // first read, which a load-time-only cache would never see. + writeFileSync( + join(root, GROUP_FILE), + groupSource.replace( + '
', + '
' + ) + ); + const drifted = snapshot.structureOf(GROUP_FILE); + expect(drifted).toMatchObject({ ok: false, reason: 'diverged' }); + + // Reverting the file restores the answer — the refusal was about the + // file's content, not about the session's history. + writeFileSync(join(root, GROUP_FILE), groupSource); + expect(snapshot.structureOf(GROUP_FILE).ok).toBe(true); + }); + + it('flips ok → source-missing when the file disappears', () => { + const root = scratchSourceRoot(); + const snapshot = loadSnapshot(FIXTURE, { sourceRoot: root }); + + expect(snapshot.structureOf(GROUP_FILE).ok).toBe(true); + rmSync(join(root, GROUP_FILE)); + expect(snapshot.structureOf(GROUP_FILE)).toMatchObject({ + ok: false, + reason: 'source-missing', + }); + }); +}); + +describe('revalidate detects a changed artifact set', () => { + it('reports fresh while the artifacts are untouched', () => { + const dir = scratchArtifacts(); + const snapshot = loadSnapshot(dir, { sourceRoot: SOURCE_ROOT }); + expect(snapshot.revalidate()).toEqual({ fresh: true }); + }); + + it('names the changed artifact when the manifest is rebuilt', () => { + const dir = scratchArtifacts(); + const snapshot = loadSnapshot(dir, { sourceRoot: SOURCE_ROOT }); + + const manifestPath = join(dir, 'manifest.json'); + writeFileSync(manifestPath, `${readFileSync(manifestPath, 'utf8')}\n`); + + const freshness = snapshot.revalidate(); + expect(freshness.fresh).toBe(false); + if (!freshness.fresh) { + expect(freshness.changed).toContain('manifest.json'); + } + }); + + it('treats a vanished commit record as a change, not an equivalence', () => { + const dir = scratchArtifacts(); + const snapshot = loadSnapshot(dir, { sourceRoot: SOURCE_ROOT }); + + rmSync(join(dir, 'commit.json')); + const freshness = snapshot.revalidate(); + expect(freshness.fresh).toBe(false); + if (!freshness.fresh) { + expect(freshness.changed).toContain('commit.json'); + } + }); +}); diff --git a/packages/oracle/src/cli.ts b/packages/oracle/src/cli.ts index f7f62948..8064b37c 100644 --- a/packages/oracle/src/cli.ts +++ b/packages/oracle/src/cli.ts @@ -27,6 +27,7 @@ export const main = async (): Promise => { process.exitCode = await runCli(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, + stdin: process.stdin, }); }; diff --git a/packages/oracle/src/cli/run.ts b/packages/oracle/src/cli/run.ts index 24988046..c92a97e4 100644 --- a/packages/oracle/src/cli/run.ts +++ b/packages/oracle/src/cli/run.ts @@ -22,6 +22,8 @@ import { SYMPTOM_KINDS } from '../engines/explain'; import { createOracle } from '../engines/oracle'; import { createAnimusHost } from '../host/animus/host'; import { loadAnimusArtifacts } from '../host/animus/loader'; +import { checkSnapshot } from '../places/check'; +import { loadSnapshot } from '../places/snapshot'; import { parseAssertion, parseForce, @@ -34,6 +36,7 @@ import { } from './args'; import { renderJson } from './json'; import { renderEquivalence, renderProbe } from './render'; +import { runSession } from './session'; import { USAGE } from './usage'; import type { RuleId } from '../core/identity'; @@ -53,6 +56,8 @@ export interface CliStream { export interface CliStreams { stdout: CliStream; stderr: CliStream; + /** Required only by `session`, which reads JSONL requests from it. */ + stdin?: AsyncIterable; } export const EXIT_OK = 0; @@ -71,6 +76,8 @@ const COMMANDS = [ 'prove', 'refine', 'classes', + 'check', + 'session', ]; /** @@ -110,6 +117,7 @@ const parse = (argv: readonly string[]) => allowPositionals: true, options: { dir: { type: 'string' }, + 'source-root': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' }, target: { type: 'string' }, @@ -288,12 +296,65 @@ const emitProbe = ( return exitCodeForVerdict(result.verdict); }; -const execute = ( - command: string, +/** `check` — the correspondence guard as a CI gate (PLACES.md §6). */ +const executeCheck = ( + dir: string, values: CliValues, io: CliStreams ): number => { + const sourceRoot = values['source-root']; + const snapshot = loadSnapshot(dir, { + ...(sourceRoot === undefined + ? {} + : { sourceRoot: resolve(process.cwd(), sourceRoot) }), + }); + const report = checkSnapshot(snapshot); + + if (values.json === true) { + io.stdout.write(renderJson({ command: 'check', result: report })); + } else { + const failing = report.files.filter((entry) => !entry.ok); + io.stderr.write( + `[animus-oracle] check: ${report.files.length} file(s) against ` + + `generation ${report.generation ?? report.programHash}\n` + ); + for (const entry of failing) { + io.stderr.write(` ${entry.file}: ${entry.reason} — ${entry.detail}\n`); + for (const divergence of entry.divergences ?? []) { + io.stderr.write(` ${divergence}\n`); + } + } + io.stderr.write( + failing.length === 0 + ? ' every file still corresponds to this generation\n' + : ` ${failing.length} file(s) no longer correspond\n` + ); + } + return report.ok ? EXIT_OK : EXIT_DISPROVED; +}; + +const execute = async ( + command: string, + values: CliValues, + io: CliStreams +): Promise => { const dir = resolve(process.cwd(), values.dir ?? DEFAULT_ARTIFACT_DIR); + + if (command === 'check') return executeCheck(dir, values, io); + if (command === 'session') { + if (io.stdin === undefined) { + throw new UsageError('session: stdin is required for the JSONL loop'); + } + const sourceRoot = values['source-root']; + return runSession( + dir, + sourceRoot === undefined + ? {} + : { sourceRoot: resolve(process.cwd(), sourceRoot) }, + { stdin: io.stdin, stdout: io.stdout, stderr: io.stderr } + ); + } + const host = createAnimusHost(loadAnimusArtifacts(dir)); const oracle = createOracle(host); const point = @@ -450,7 +511,7 @@ export const runCli = async ( } try { - return execute(command, values, io); + return await execute(command, values, io); } catch (error) { io.stderr.write( `[animus-oracle] ${String((error as Error).message ?? error)}\n` diff --git a/packages/oracle/src/cli/session.ts b/packages/oracle/src/cli/session.ts new file mode 100644 index 00000000..b0678812 --- /dev/null +++ b/packages/oracle/src/cli/session.ts @@ -0,0 +1,364 @@ +import { createPlaceAnalysis, loadSnapshot } from '../places'; +import { checkSnapshot } from '../places/check'; +import { UsageError } from './args'; + +import type { WorldDelta } from '../core/world'; +import type { + InvocationRef, + Observation, + PlaceAnalysis, + Snapshot, +} from '../places'; + +/** + * Warm operation (PLACES.md §6): one loaded snapshot answering many + * questions. The protocol is JSONL — one request object per line on stdin, + * one response object per line on stdout — so an editor or agent holds a + * conversation without paying the artifact parse per question. + * + * The warmth never outlives the truth. Source files are re-read and + * correspondence-checked per question (`structureOf`), and the artifact set + * is revalidated per request: a rebuilt `.animus` directory turns every + * subsequent answer into an explicit `stale-snapshot` refusal telling the + * client to restart, never a quiet answer from a dead generation. + */ + +export interface SessionRequest { + id?: number | string; + op: string; +} + +export type SessionResponse = + | { + id?: number | string; + ok: true; + op: string; + result: unknown; + } + | { + id?: number | string; + ok: false; + kind: 'usage' | 'refused' | 'stale-snapshot' | 'environment'; + error: string; + changed?: readonly string[]; + }; + +export interface SessionOutcome { + response: SessionResponse; + close: boolean; +} + +export interface PlacesSession { + snapshot: Snapshot; + analysis: PlaceAnalysis; + handle(request: unknown): SessionOutcome; + handleLine(line: string): SessionOutcome; +} + +export const SESSION_OPS = [ + 'snapshot', + 'check', + 'files', + 'invocations', + 'unresolved', + 'at', + 'place', + 'explain', + 'carry', + 'locate', + 'observe', + 'shutdown', +] as const; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const requireString = ( + request: Record, + key: string, + op: string +): string => { + const value = request[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new UsageError(`${op}: '${key}' must be a non-empty string`); + } + return value; +}; + +const requireNumber = ( + request: Record, + key: string, + op: string +): number => { + const value = request[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new UsageError(`${op}: '${key}' must be a finite number`); + } + return value; +}; + +const OBSERVATION_SOURCES = ['dom', 'ssr', 'classes'] as const; + +const requireObservation = ( + request: Record, + op: string +): Observation => { + const value = request['observation']; + if (!isRecord(value)) { + throw new UsageError(`${op}: 'observation' must be an object`); + } + const source = value.source; + if ( + typeof source !== 'string' || + !OBSERVATION_SOURCES.some((name) => name === source) + ) { + throw new UsageError( + `${op}: observation.source must be one of ` + + OBSERVATION_SOURCES.join(', ') + ); + } + return value as unknown as Observation; +}; + +export interface SessionOptions { + sourceRoot?: string; +} + +export const createPlacesSession = ( + artifactsDir: string, + options: SessionOptions = {} +): PlacesSession => { + const snapshot = loadSnapshot(artifactsDir, { + ...(options.sourceRoot === undefined + ? {} + : { sourceRoot: options.sourceRoot }), + }); + const analysis = createPlaceAnalysis(snapshot); + + /** File-scoped ops surface the correspondence refusal, never a bare null. */ + const readableInvocationAt = ( + request: Record, + op: string + ): InvocationRef => { + const file = requireString(request, 'file', op); + const offset = requireNumber(request, 'offset', op); + const structure = snapshot.structureOf(file); + if (!structure.ok) { + throw new RefusalError(structure.detail); + } + const invocation = analysis.at(file, offset); + if (invocation === undefined) { + throw new UsageError( + `${op}: no component invocation in ${file} contains offset ${offset}` + ); + } + return invocation; + }; + + const dispatch = (op: string, request: Record): unknown => { + switch (op) { + case 'snapshot': + return { + generation: snapshot.generation, + programHash: snapshot.host.program.hash, + sourceRoot: snapshot.sourceRoot, + files: snapshot.files().length, + freshness: snapshot.revalidate(), + }; + case 'check': + return checkSnapshot(snapshot); + case 'files': + return snapshot.files(); + case 'invocations': + return analysis.invocationsOf(requireString(request, 'component', op)); + case 'unresolved': { + const file = requireString(request, 'file', op); + const structure = snapshot.structureOf(file); + if (!structure.ok) throw new RefusalError(structure.detail); + return analysis.unresolved(file); + } + case 'at': + return readableInvocationAt(request, op); + case 'place': + return analysis.placeOf(readableInvocationAt(request, op)); + case 'explain': { + const place = analysis.placeOf(readableInvocationAt(request, op)); + const at = request['at']; + if (at !== undefined && !isRecord(at)) { + throw new UsageError(`${op}: 'at' must be an object of bindings`); + } + return analysis.explain(place, { + property: requireString(request, 'property', op), + ...(at === undefined + ? {} + : { at: at as Record }), + }); + } + case 'carry': { + const deltas = request['deltas']; + if (!Array.isArray(deltas) || deltas.length === 0) { + throw new UsageError( + `${op}: 'deltas' must be a non-empty array of world deltas` + ); + } + return analysis.carry(deltas as WorldDelta[], { + component: requireString(request, 'component', op), + property: requireString(request, 'property', op), + }); + } + case 'locate': + return analysis.locate(requireObservation(request, op)); + case 'observe': + return analysis.observe( + analysis.placeOf(readableInvocationAt(request, op)), + requireObservation(request, op) + ); + default: + throw new UsageError( + `unknown op '${op}' — supported: ${SESSION_OPS.join(', ')}` + ); + } + }; + + const handle = (request: unknown): SessionOutcome => { + if (!isRecord(request) || typeof request.op !== 'string') { + return { + response: { + ok: false, + kind: 'usage', + error: "a request is an object with an 'op' string", + }, + close: false, + }; + } + const id = request.id; + const idField = + typeof id === 'number' || typeof id === 'string' ? { id } : {}; + const op = request.op; + + if (op === 'shutdown') { + return { + response: { ...idField, ok: true, op, result: { closing: true } }, + close: true, + }; + } + + // `snapshot` answers even when stale — it is how a client learns what it + // is talking to; every other op refuses on a dead generation. + if (op !== 'snapshot') { + const freshness = snapshot.revalidate(); + if (!freshness.fresh) { + return { + response: { + ...idField, + ok: false, + kind: 'stale-snapshot', + error: + 'the artifact set changed since this session loaded it — ' + + 'restart the session against the rebuilt artifacts', + changed: freshness.changed, + }, + close: false, + }; + } + } + + try { + return { + response: { ...idField, ok: true, op, result: dispatch(op, request) }, + close: false, + }; + } catch (error) { + if (error instanceof RefusalError) { + return { + response: { + ...idField, + ok: false, + kind: 'refused', + error: error.message, + }, + close: false, + }; + } + const usage = error instanceof UsageError || error instanceof TypeError; + return { + response: { + ...idField, + ok: false, + kind: usage ? 'usage' : 'environment', + error: String((error as Error).message ?? error), + }, + close: false, + }; + } + }; + + const handleLine = (line: string): SessionOutcome => { + let request: unknown; + try { + request = JSON.parse(line) as unknown; + } catch { + return { + response: { + ok: false, + kind: 'usage', + error: `not valid JSON: ${line.slice(0, 80)}`, + }, + close: false, + }; + } + return handle(request); + }; + + return { snapshot, analysis, handle, handleLine }; +}; + +/** A correspondence refusal — an answer about generations, not an error. */ +class RefusalError extends Error {} + +export interface SessionStreams { + stdin: AsyncIterable; + stdout: { write(text: string): unknown }; + stderr: { write(text: string): unknown }; +} + +/** + * The stream loop: JSONL in, JSONL out, human narration on stderr only. + * Returns 0 on a clean shutdown or stdin EOF — a session that ends is not a + * verdict. + */ +export const runSession = async ( + artifactsDir: string, + options: SessionOptions, + io: SessionStreams +): Promise => { + const session = createPlacesSession(artifactsDir, options); + io.stderr.write( + `[animus-oracle] session open — generation ` + + `${session.snapshot.generation ?? session.snapshot.host.program.hash}, ` + + `${session.snapshot.files().length} file(s); one JSON request per ` + + 'line, `{"op":"shutdown"}` to close\n' + ); + + const decoder = new TextDecoder(); + let buffer = ''; + const emit = (line: string): boolean => { + const outcome = session.handleLine(line); + io.stdout.write(`${JSON.stringify(outcome.response)}\n`); + return outcome.close; + }; + + for await (const chunk of io.stdin) { + buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk); + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line.length > 0 && emit(line)) return 0; + newline = buffer.indexOf('\n'); + } + } + const trailing = buffer.trim(); + if (trailing.length > 0) emit(trailing); + return 0; +}; diff --git a/packages/oracle/src/cli/usage.ts b/packages/oracle/src/cli/usage.ts index 8d9a86a1..e82d3547 100644 --- a/packages/oracle/src/cli/usage.ts +++ b/packages/oracle/src/cli/usage.ts @@ -17,6 +17,10 @@ Commands: prove Does this invariant hold across the declared domain? refine Discharge one unknown obligation as cheaply as possible classes Render-equivalence classes of this target's scenario domain + check Does the working tree still correspond to these artifacts, + file by file? (the places correspondence guard as a CI gate) + session Warm JSONL loop over one snapshot: one request object per stdin + line, one response per stdout line (PLACES.md §6) Per-command options: inspect --target [--at ] @@ -28,6 +32,14 @@ Per-command options: [--max-cells ] refine --obligation classes --target + check [--source-root ] + session [--source-root ] + +Session ops (\`{"id":1,"op":"...",...}\` per line): snapshot, check, files, +invocations(component), unresolved(file), at(file,offset), place(file,offset), +explain(file,offset,property[,at]), carry(component,property,deltas), +locate(observation), observe(file,offset,observation), shutdown. Every op but +\`snapshot\` refuses with \`stale-snapshot\` once the artifact set is rebuilt. Shared options: --dir Artifact directory written by \`animus build\` @@ -68,8 +80,9 @@ Deltas (repeatable; simulate and diff): no-important Exit codes: - 0 PROVED / ESTABLISHED / FIXPOINT - 1 DISPROVED + 0 PROVED / ESTABLISHED / FIXPOINT; \`check\` fully corresponding; a + \`session\` that ended cleanly + 1 DISPROVED; \`check\` found files that no longer correspond 2 usage error (unknown command or flag, malformed value, bad request) 3 environment error (the artifact directory is missing or unreadable) 4 CONDITIONAL / INCONCLUSIVE / OUTSIDE_MODEL — completed, but not clean diff --git a/packages/oracle/src/places/analysis.ts b/packages/oracle/src/places/analysis.ts index 1cc3f251..273b7ed3 100644 --- a/packages/oracle/src/places/analysis.ts +++ b/packages/oracle/src/places/analysis.ts @@ -3,7 +3,14 @@ import { applyDeltas } from '../core/world'; import { pinDomain } from '../engines/cells'; import { readCascade } from '../engines/inspect'; import { createRuntime } from '../engines/runtime'; -import { analyzeSelector, canonicalCompound } from '../host/animus/selector'; +import { elementSatisfies, requirementOf } from './axes'; +import { + dischargeObservation, + impliedModeOf, + invertObservedClasses, + scorePlace, +} from './observation'; +import { resolveComponentTag } from './resolve'; import { ancestorsOf } from './source'; import type { ScenarioDomain, ScenarioPoint } from '../core/scenario'; @@ -11,6 +18,20 @@ import type { WorldDelta } from '../core/world'; import type { OracleRuntime } from '../engines/runtime'; import type { ComponentRecord } from '../providers/identity'; import type { StyleRuleRecord } from '../providers/style-universe'; +import type { + AxisBinding, + InvocationRef, + OpenReason, + Place, + UnresolvedInvocation, +} from './model'; +import type { + LocateCandidate, + LocateMatch, + LocateResult, + Observation, + ObserveResult, +} from './observation'; import type { Snapshot } from './snapshot'; import type { SourceElement, SourceRead } from './source'; @@ -20,37 +41,13 @@ import type { SourceElement, SourceRead } from './source'; * and outcomes carried across every place that matters. */ -export interface InvocationRef { - file: string; - ordinal: number; - span: readonly [number, number]; - component: ComponentRecord; -} - -export type OpenReason = - | 'opaque-component' - | 'dynamic-attribute' - | 'spread-attributes' - | 'stateful-pseudo' - | 'unmodeled-relation'; - -export interface AxisBinding { - axis: string; - state: 'established' | 'refuted' | 'open'; - reason?: OpenReason; - /** The ancestor that establishes the axis or opens the question. */ - witness?: { file: string; ordinal: number; tag: string }; -} - -export interface Place { - invocation: InvocationRef; - bindings: readonly AxisBinding[]; - /** What a refutation is scoped to — never silently assumed. */ - assumptions: readonly string[]; - /** The scenario override pinning every decided axis. */ - pinned: ScenarioDomain; - point: ScenarioPoint; -} +export type { + AxisBinding, + InvocationRef, + OpenReason, + Place, + UnresolvedInvocation, +} from './model'; export interface PlaceExplanation { place: Place; @@ -89,6 +86,11 @@ export interface PlaceAnalysis { snapshot: Snapshot; /** Every correspondence-checked invocation of one component. */ invocationsOf(selector: string): readonly InvocationRef[]; + /** + * Component-like tags in one file that cannot be attributed to a single + * component — surfaced with their candidates, never silently dropped. + */ + unresolved(file: string): readonly UnresolvedInvocation[]; /** The invocation whose element span contains this offset. */ at(file: string, offset: number): InvocationRef | undefined; placeOf(invocation: InvocationRef): Place; @@ -105,76 +107,20 @@ export interface PlaceAnalysis { deltas: readonly WorldDelta[], subject: { component: string; property: string } ): readonly CarriedOutcome[]; + /** + * The observation-first entry (PLACES.md §5): which components produced + * these observed classes, at which replay-verified bindings, and which + * places could have rendered them — a narrowing, never a pick. + */ + locate(observation: Observation): LocateResult; + /** + * Apply an observation to a place: open axes discharge with observation + * evidence, refutation demands a complete chain, and a contradiction with + * static structure rejects the whole observation (PLACES.md §5). + */ + observe(place: Place, observation: Observation): ObserveResult; } -interface AxisRequirement { - classNames: readonly string[]; - attributes: readonly string[]; - stateful: boolean; - /** Undefined when the prefix is more than one descendant compound. */ - modeled: boolean; -} - -const requirementOf = (axis: string): AxisRequirement => { - const prefix = axis.slice('ancestor:'.length); - const analyzed = analyzeSelector(`${prefix} .__axis-probe__`); - const links = analyzed.model.ancestry ?? []; - if (links.length !== 1 || links[0].combinator !== 'descendant') { - return { classNames: [], attributes: [], stateful: false, modeled: false }; - } - const model = links[0].model; - return { - classNames: model.classNames, - attributes: (model.attributes ?? []).map(canonicalCompound), - stateful: (model.pseudo ?? []).length > 0, - modeled: true, - }; -}; - -const classListOf = (element: SourceElement): readonly string[] | undefined => { - const className = element.attributes.find((a) => a.name === 'className'); - if (className === undefined) return element.hasSpread ? undefined : []; - if (className.kind !== 'static') return undefined; - return (className.value ?? '').split(/\s+/).filter((name) => name !== ''); -}; - -const attributeMatch = ( - element: SourceElement, - required: string -): 'yes' | 'no' | 'unknown' => { - const parsed = /^\[([^\]=]+)(?:=([^\]]*))?\]$/.exec(required); - if (parsed === null) return 'unknown'; - const name = parsed[1]; - const value = parsed[2]?.replace(/^["']|["']$/g, ''); - const attr = element.attributes.find((a) => a.name === name); - if (attr === undefined) return element.hasSpread ? 'unknown' : 'no'; - if (attr.kind !== 'static') return 'unknown'; - if (value === undefined) return 'yes'; - return attr.value === value ? 'yes' : 'no'; -}; - -/** How one structural ancestor relates to one axis requirement. */ -const elementSatisfies = ( - element: SourceElement, - requirement: AxisRequirement -): 'yes' | 'no' | 'unknown' => { - let unknown = false; - - for (const attribute of requirement.attributes) { - const verdict = attributeMatch(element, attribute); - if (verdict === 'no') return 'no'; - if (verdict === 'unknown') unknown = true; - } - if (requirement.classNames.length > 0) { - const classes = classListOf(element); - if (classes === undefined) unknown = true; - else if (!requirement.classNames.every((name) => classes.includes(name))) { - return 'no'; - } - } - return unknown ? 'unknown' : 'yes'; -}; - const bindAxis = ( axis: string, read: SourceRead, @@ -285,46 +231,63 @@ export const createPlaceAnalysis = (snapshot: Snapshot): PlaceAnalysis => { return Array.from(axes).sort(); }; - /** Resolve a JSX tag in one file to an extracted component, or undefined. */ - const componentForTag = ( - file: string, - tag: string - ): ComponentRecord | undefined => { - const local = components.find( - (component) => component.file === file && component.binding === tag - ); - if (local !== undefined) return local; - - const imported = snapshot - .fileFacts(file) - ?.imports?.find((entry) => entry.local === tag); - const name = imported?.imported ?? tag; - const matches = components.filter( - (component) => component.binding === name - ); - // An ambiguous bare binding resolves to nothing rather than to an - // arbitrary winner — same contract as IdentityProvider.resolveTarget. - return matches.length === 1 ? matches[0] : undefined; - }; - const invocationsIn = (file: string): InvocationRef[] => { const structure = snapshot.structureOf(file); if (!structure.ok) return []; const refs: InvocationRef[] = []; for (const element of structure.read.elements) { if (!element.component) continue; - const component = componentForTag(file, element.tag); - if (component === undefined) continue; + const resolution = resolveComponentTag( + components, + snapshot.fileFacts(file)?.imports, + file, + element.tag + ); + if (resolution.kind !== 'resolved') continue; refs.push({ file, ordinal: element.ordinal, span: element.span, - component, + component: resolution.component, }); } return refs; }; + /** + * Component-like tags this analysis cannot attribute to one component — + * surfaced, never silently dropped. Tags outside the universe are not + * listed: a plain wrapper component is an opaque boundary, not a failed + * attribution. + */ + const unresolved = (file: string): UnresolvedInvocation[] => { + const structure = snapshot.structureOf(file); + if (!structure.ok) return []; + const entries: UnresolvedInvocation[] = []; + for (const element of structure.read.elements) { + if (!element.component) continue; + const resolution = resolveComponentTag( + components, + snapshot.fileFacts(file)?.imports, + file, + element.tag + ); + if (resolution.kind !== 'ambiguous') continue; + entries.push({ + file, + ordinal: element.ordinal, + span: element.span, + tag: element.tag, + reason: 'ambiguous-binding', + candidates: resolution.candidates.map((candidate) => candidate.id), + ...(resolution.specifier === undefined + ? {} + : { specifier: resolution.specifier }), + }); + } + return entries; + }; + const invocationsOf = (selector: string): InvocationRef[] => snapshot .files() @@ -343,6 +306,26 @@ export const createPlaceAnalysis = (snapshot: Snapshot): PlaceAnalysis => { return candidates[0]; }; + /** Fold decided bindings into the pinned domain + point of a place. */ + const placeFrom = ( + invocation: InvocationRef, + bindings: readonly AxisBinding[], + assumptions: readonly string[] + ): Place => { + const pinned: Record = {}; + const point: Record = {}; + for (const binding of bindings) { + if (binding.state === 'established') { + pinned[binding.axis] = { kind: 'finite', values: [true] }; + point[binding.axis] = true; + } else if (binding.state === 'refuted') { + pinned[binding.axis] = { kind: 'finite', values: [false] }; + point[binding.axis] = false; + } + } + return { invocation, bindings, assumptions, pinned, point }; + }; + const placeOf = (invocation: InvocationRef): Place => { const structure = snapshot.structureOf(invocation.file); if (!structure.ok) { @@ -353,23 +336,72 @@ export const createPlaceAnalysis = (snapshot: Snapshot): PlaceAnalysis => { } const bindings: AxisBinding[] = []; const assumptions: string[] = []; - const pinned: Record = {}; - const point: Record = {}; for (const axis of ancestorAxesOf(invocation.component)) { const bound = bindAxis(axis, structure.read, invocation); bindings.push(bound.binding); if (bound.assumption !== undefined) assumptions.push(bound.assumption); - if (bound.binding.state === 'established') { - pinned[axis] = { kind: 'finite', values: [true] }; - point[axis] = true; - } else if (bound.binding.state === 'refuted') { - pinned[axis] = { kind: 'finite', values: [false] }; - point[axis] = false; - } } - return { invocation, bindings, assumptions, pinned, point }; + return placeFrom(invocation, bindings, assumptions); + }; + + const locate = (observation: Observation): LocateResult => { + const subjectClasses = observation.subject?.classes ?? []; + const matchedClasses = new Set(); + const matches: LocateMatch[] = []; + const modeDomain = snapshot.host.scenarios.dimensions()['mode']; + + for (const record of components) { + if (!subjectClasses.includes(record.className)) continue; + const resolution = snapshot.host.identity.resolveTarget(record.id); + if (resolution === undefined) continue; + + const inversion = invertObservedClasses(resolution, subjectClasses); + for (const name of inversion.matched) matchedClasses.add(name); + const mode = impliedModeOf(observation, modeDomain); + + const candidates: LocateCandidate[] = invocationsOf(record.id).map( + (invocation) => { + const place = placeOf(invocation); + const score = scorePlace(place, observation); + return { place, verdict: score.verdict, notes: score.notes }; + } + ); + + matches.push({ + component: record, + impliedPoint: { ...inversion.point, ...mode.point }, + conflicts: [...inversion.conflicts, ...mode.conflicts], + candidates, + }); + } + + return { + matches, + unmatchedClasses: subjectClasses.filter( + (name) => !matchedClasses.has(name) + ), + }; + }; + + const observe = (place: Place, observation: Observation): ObserveResult => { + const result = dischargeObservation(place.bindings, observation); + if (result.contradictions.length > 0) { + return { + place, + discharged: [], + contradictions: result.contradictions, + }; + } + return { + place: placeFrom(place.invocation, result.bindings, [ + ...place.assumptions, + ...result.assumptions, + ]), + discharged: result.discharged, + contradictions: [], + }; }; const explain = ( @@ -506,9 +538,12 @@ export const createPlaceAnalysis = (snapshot: Snapshot): PlaceAnalysis => { return { snapshot, invocationsOf, + unresolved, at, placeOf, explain, carry, + locate, + observe, }; }; diff --git a/packages/oracle/src/places/axes.ts b/packages/oracle/src/places/axes.ts new file mode 100644 index 00000000..85f6450a --- /dev/null +++ b/packages/oracle/src/places/axes.ts @@ -0,0 +1,92 @@ +import { analyzeSelector, canonicalCompound } from '../host/animus/selector'; + +import type { SourceElement } from './source'; + +/** + * What an `ancestor:` axis demands of an ancestor element — shared by + * the static structural matcher (source elements) and the observation matcher + * (rendered elements, PLACES.md §5). One requirement reading, two witnesses. + */ + +export type MatchVerdict = 'yes' | 'no' | 'unknown'; + +export interface AxisRequirement { + classNames: readonly string[]; + attributes: readonly string[]; + stateful: boolean; + /** Undefined when the prefix is more than one descendant compound. */ + modeled: boolean; +} + +export const requirementOf = (axis: string): AxisRequirement => { + const prefix = axis.slice('ancestor:'.length); + const analyzed = analyzeSelector(`${prefix} .__axis-probe__`); + const links = analyzed.model.ancestry ?? []; + if (links.length !== 1 || links[0].combinator !== 'descendant') { + return { classNames: [], attributes: [], stateful: false, modeled: false }; + } + const model = links[0].model; + return { + classNames: model.classNames, + attributes: (model.attributes ?? []).map(canonicalCompound), + stateful: (model.pseudo ?? []).length > 0, + modeled: true, + }; +}; + +export interface AttributeRequirement { + name: string; + /** Absent for a bare `[name]` requirement. */ + value?: string; +} + +export const parseAttributeRequirement = ( + raw: string +): AttributeRequirement | undefined => { + const parsed = /^\[([^\]=]+)(?:=([^\]]*))?\]$/.exec(raw); + if (parsed === null) return undefined; + const value = parsed[2]?.replace(/^["']|["']$/g, ''); + return { name: parsed[1], ...(value === undefined ? {} : { value }) }; +}; + +const classListOf = (element: SourceElement): readonly string[] | undefined => { + const className = element.attributes.find((a) => a.name === 'className'); + if (className === undefined) return element.hasSpread ? undefined : []; + if (className.kind !== 'static') return undefined; + return (className.value ?? '').split(/\s+/).filter((name) => name !== ''); +}; + +const attributeMatch = ( + element: SourceElement, + required: string +): MatchVerdict => { + const requirement = parseAttributeRequirement(required); + if (requirement === undefined) return 'unknown'; + const attr = element.attributes.find((a) => a.name === requirement.name); + if (attr === undefined) return element.hasSpread ? 'unknown' : 'no'; + if (attr.kind !== 'static') return 'unknown'; + if (requirement.value === undefined) return 'yes'; + return attr.value === requirement.value ? 'yes' : 'no'; +}; + +/** How one structural ancestor relates to one axis requirement. */ +export const elementSatisfies = ( + element: SourceElement, + requirement: AxisRequirement +): MatchVerdict => { + let unknown = false; + + for (const attribute of requirement.attributes) { + const verdict = attributeMatch(element, attribute); + if (verdict === 'no') return 'no'; + if (verdict === 'unknown') unknown = true; + } + if (requirement.classNames.length > 0) { + const classes = classListOf(element); + if (classes === undefined) unknown = true; + else if (!requirement.classNames.every((name) => classes.includes(name))) { + return 'no'; + } + } + return unknown ? 'unknown' : 'yes'; +}; diff --git a/packages/oracle/src/places/check.ts b/packages/oracle/src/places/check.ts new file mode 100644 index 00000000..12891d72 --- /dev/null +++ b/packages/oracle/src/places/check.ts @@ -0,0 +1,45 @@ +import type { Snapshot, StructureResult } from './snapshot'; + +/** + * The correspondence guard as a batch gate (PLACES.md §6): does the working + * tree still correspond to the generation these artifacts describe, file by + * file? A failing entry is a settled negative answer about staleness — the + * CI use the charter gates on correspondence being credible. + */ + +export interface CheckEntry { + file: string; + ok: boolean; + reason?: Extract['reason']; + detail?: string; + divergences?: readonly string[]; +} + +export interface CheckReport { + ok: boolean; + generation: string | undefined; + programHash: string; + files: readonly CheckEntry[]; +} + +export const checkSnapshot = (snapshot: Snapshot): CheckReport => { + const files = snapshot.files().map((file): CheckEntry => { + const result = snapshot.structureOf(file); + if (result.ok) return { file, ok: true }; + return { + file, + ok: false, + reason: result.reason, + detail: result.detail, + ...(result.divergences === undefined + ? {} + : { divergences: result.divergences }), + }; + }); + return { + ok: files.every((entry) => entry.ok), + generation: snapshot.generation, + programHash: snapshot.host.program.hash, + files, + }; +}; diff --git a/packages/oracle/src/places/compare.ts b/packages/oracle/src/places/compare.ts new file mode 100644 index 00000000..290c43b7 Binary files /dev/null and b/packages/oracle/src/places/compare.ts differ diff --git a/packages/oracle/src/places/index.ts b/packages/oracle/src/places/index.ts index 6b38abda..eff997ae 100644 --- a/packages/oracle/src/places/index.ts +++ b/packages/oracle/src/places/index.ts @@ -1,17 +1,45 @@ export { loadSnapshot } from './snapshot'; -export type { Snapshot, SnapshotOptions, StructureResult } from './snapshot'; +export type { + Snapshot, + SnapshotFreshness, + SnapshotOptions, + StructureResult, +} from './snapshot'; + +export { checkSnapshot } from './check'; +export type { CheckEntry, CheckReport } from './check'; + +export { compareSnapshots } from './compare'; +export type { + BindingChange, + ComparedPlace, + CompareRefusal, + SnapshotComparison, +} from './compare'; export { ancestorsOf, readSourceStructure } from './source'; export type { SourceAttribute, SourceElement, SourceRead } from './source'; export { createPlaceAnalysis } from './analysis'; export type { - AxisBinding, CarriedOutcome, - InvocationRef, - OpenReason, OutcomeClass, - Place, PlaceAnalysis, PlaceExplanation, } from './analysis'; +export type { + AxisBinding, + InvocationRef, + ObservationSource, + OpenReason, + Place, + UnresolvedInvocation, +} from './model'; +export type { + LocateCandidate, + LocateMatch, + LocateResult, + Observation, + ObservedElement, + ObserveResult, +} from './observation'; diff --git a/packages/oracle/src/places/model.ts b/packages/oracle/src/places/model.ts new file mode 100644 index 00000000..e7ac00e0 --- /dev/null +++ b/packages/oracle/src/places/model.ts @@ -0,0 +1,62 @@ +import type { ScenarioDomain, ScenarioPoint } from '../core/scenario'; +import type { ComponentRecord } from '../providers/identity'; + +/** + * The many-place model (PLACES.md §2): invocations found in real source, + * places built from their structural context, and ancestor axes bound per + * place. Bindings decided by an observation (PLACES.md §5) carry `evidence` + * so observation authority stays visible wherever the answer flows. + */ + +export interface InvocationRef { + file: string; + ordinal: number; + span: readonly [number, number]; + component: ComponentRecord; +} + +export type OpenReason = + | 'opaque-component' + | 'dynamic-attribute' + | 'spread-attributes' + | 'stateful-pseudo' + | 'unmodeled-relation'; + +/** Where an observation came from — recorded with every discharge. */ +export type ObservationSource = 'dom' | 'ssr' | 'classes'; + +export interface AxisBinding { + axis: string; + state: 'established' | 'refuted' | 'open'; + reason?: OpenReason; + /** The ancestor that establishes the axis or opens the question. */ + witness?: { file: string; ordinal: number; tag: string }; + /** Present when an observation, not static structure, decided the state. */ + evidence?: { source: ObservationSource; note?: string }; +} + +/** + * A component-like tag the analysis cannot attribute to one component — + * surfaced instead of silently dropped (seam S4's honesty boundary). + */ +export interface UnresolvedInvocation { + file: string; + ordinal: number; + span: readonly [number, number]; + tag: string; + reason: 'ambiguous-binding'; + /** The component ids the binding could refer to. */ + candidates: readonly string[]; + /** The import specifier that could not decide, when one exists. */ + specifier?: string; +} + +export interface Place { + invocation: InvocationRef; + bindings: readonly AxisBinding[]; + /** What a refutation is scoped to — never silently assumed. */ + assumptions: readonly string[]; + /** The scenario override pinning every decided axis. */ + pinned: ScenarioDomain; + point: ScenarioPoint; +} diff --git a/packages/oracle/src/places/observation.ts b/packages/oracle/src/places/observation.ts new file mode 100644 index 00000000..faa57dd0 --- /dev/null +++ b/packages/oracle/src/places/observation.ts @@ -0,0 +1,415 @@ +import { MODE_DIMENSION } from '../host/animus/conditions'; +import { MODE_SELECTOR } from '../host/animus/tokens'; +import { parseAttributeRequirement, requirementOf } from './axes'; + +import type { DimensionDomain, ScenarioPoint } from '../core/scenario'; +import type { ComponentRecord, TargetResolution } from '../providers/identity'; +import type { AxisRequirement, MatchVerdict } from './axes'; +import type { AxisBinding, ObservationSource, Place } from './model'; + +/** + * Observations as evidence (PLACES.md §5): what was actually seen for one + * rendered element. Observations narrow possibilities or discharge particular + * unknowns; they never manufacture certainty, and one that contradicts the + * model is surfaced, not averaged in. + */ + +export interface ObservedElement { + tag?: string; + /** The complete class list of the element, when it was observed. */ + classes?: readonly string[]; + /** The complete attribute map of the element, when it was observed. */ + attributes?: Readonly>; +} + +export interface Observation { + source: ObservationSource; + /** The observed element itself — its class list is `locate`'s entry key. */ + subject?: ObservedElement; + /** Observed ancestor chain, innermost first. */ + ancestors?: readonly ObservedElement[]; + /** True when the chain reaches the document root — required to refute. */ + completeToRoot?: boolean; +} + +export interface LocateCandidate { + place: Place; + /** + * `conditional` = possible only if a scoped refutation's beyond-file-root + * assumption fails; the note names the scope. + */ + verdict: 'consistent' | 'conditional' | 'contradicted'; + notes: readonly string[]; +} + +export interface LocateMatch { + component: ComponentRecord; + /** Replay-verified bindings + the observed mode, never a guess. */ + impliedPoint: ScenarioPoint; + conflicts: readonly string[]; + candidates: readonly LocateCandidate[]; +} + +export interface LocateResult { + matches: readonly LocateMatch[]; + /** Observed subject classes that mean nothing in this snapshot. */ + unmatchedClasses: readonly string[]; +} + +export interface ObserveResult { + place: Place; + /** Bindings whose state this observation decided. */ + discharged: readonly AxisBinding[]; + /** Non-empty means the observation was rejected and nothing discharged. */ + contradictions: readonly string[]; +} + +/** Like the structural matcher, but over a rendered element: an absent + * `classes`/`attributes` field is unobserved knowledge, never emptiness. */ +const observedSatisfies = ( + element: ObservedElement, + requirement: AxisRequirement +): MatchVerdict => { + let unknown = false; + + for (const raw of requirement.attributes) { + const attribute = parseAttributeRequirement(raw); + if (attribute === undefined) { + unknown = true; + continue; + } + if (element.attributes === undefined) { + unknown = true; + continue; + } + const actual = element.attributes[attribute.name]; + if (actual === undefined) return 'no'; + if (attribute.value !== undefined && actual !== attribute.value) { + return 'no'; + } + } + if (requirement.classNames.length > 0) { + const classes = element.classes; + if (classes === undefined) unknown = true; + else if (!requirement.classNames.every((name) => classes.includes(name))) { + return 'no'; + } + } + return unknown ? 'unknown' : 'yes'; +}; + +type ChainVerdict = + | { state: 'established'; index: number; element: ObservedElement } + | { state: 'refuted' } + | { state: 'open' }; + +/** + * What the observed chain says about one axis requirement. Refutation + * demands a complete-to-root chain with no unknowns — an unseen or partial + * element could still satisfy the requirement. + */ +const chainVerdict = ( + observation: Observation, + requirement: AxisRequirement +): ChainVerdict => { + let unknown = false; + const ancestors = observation.ancestors ?? []; + for (let index = 0; index < ancestors.length; index++) { + const verdict = observedSatisfies(ancestors[index], requirement); + if (verdict === 'yes') { + return { state: 'established', index, element: ancestors[index] }; + } + if (verdict === 'unknown') unknown = true; + } + if (observation.completeToRoot === true && !unknown) { + return { state: 'refuted' }; + } + return { state: 'open' }; +}; + +const describeWitness = (verdict: ChainVerdict): string => + verdict.state === 'established' + ? `observed ancestor ${verdict.index}` + + (verdict.element.tag === undefined ? '' : ` <${verdict.element.tag}>`) + : ''; + +export interface DischargeResult { + bindings: readonly AxisBinding[]; + discharged: readonly AxisBinding[]; + contradictions: readonly string[]; + assumptions: readonly string[]; +} + +/** + * Apply one observation to a place's bindings. A contradiction with static + * structure rejects the whole observation — a chain that cannot be a render + * of this place must not partially rewrite it (the observation-generation + * analogue of the correspondence guard). + */ +export const dischargeObservation = ( + bindings: readonly AxisBinding[], + observation: Observation +): DischargeResult => { + const next: AxisBinding[] = []; + const discharged: AxisBinding[] = []; + const contradictions: string[] = []; + const assumptions: string[] = []; + const source = observation.source; + + for (const binding of bindings) { + const requirement = requirementOf(binding.axis); + if (!requirement.modeled) { + next.push(binding); + continue; + } + const verdict = chainVerdict(observation, requirement); + + if (binding.state === 'established') { + if (verdict.state === 'refuted') { + contradictions.push( + `the complete observed chain has no ancestor satisfying ` + + `'${binding.axis}', but static structure establishes it` + + (binding.witness === undefined + ? '' + : ` at <${binding.witness.tag}>`) + + ' — the observation cannot be a render of this place' + ); + } + next.push(binding); + continue; + } + + if (binding.state === 'refuted') { + if (verdict.state === 'established' && !requirement.stateful) { + const rebound: AxisBinding = { + axis: binding.axis, + state: 'established', + evidence: { source, note: describeWitness(verdict) }, + }; + next.push(rebound); + discharged.push(rebound); + assumptions.push( + `'${binding.axis}' was refuted within the file's shown structure; ` + + `the ${source} observation establishes it beyond that scope — ` + + 'the beyond-file-root assumption is discharged false for this ' + + 'render' + ); + continue; + } + if (verdict.state === 'established' && requirement.stateful) { + next.push({ + axis: binding.axis, + state: 'open', + reason: 'stateful-pseudo', + evidence: { source, note: describeWitness(verdict) }, + }); + continue; + } + next.push(binding); + continue; + } + + // binding.state === 'open' + if (verdict.state === 'established') { + if (requirement.stateful) { + // The structural half is witnessed, but a snapshot observation + // cannot see interaction state — the axis stays open. + next.push({ + axis: binding.axis, + state: 'open', + reason: 'stateful-pseudo', + evidence: { + source, + note: + `${describeWitness(verdict)} carries the structure; ` + + 'the interaction state is unobservable in a snapshot', + }, + }); + continue; + } + const established: AxisBinding = { + axis: binding.axis, + state: 'established', + evidence: { source, note: describeWitness(verdict) }, + }; + next.push(established); + discharged.push(established); + assumptions.push( + `'${binding.axis}' established by the ${source} observation — ` + + 'evidence from a rendered chain, not static structure' + ); + continue; + } + if (verdict.state === 'refuted') { + const refuted: AxisBinding = { + axis: binding.axis, + state: 'refuted', + evidence: { source }, + }; + next.push(refuted); + discharged.push(refuted); + assumptions.push( + `'${binding.axis}' refuted by the ${source} observation — no ` + + 'element of the complete observed chain satisfies it; the ' + + 'refutation is scoped to the observed chain' + ); + continue; + } + next.push(binding); + } + + if (contradictions.length > 0) { + return { bindings, discharged: [], contradictions, assumptions: [] }; + } + return { bindings: next, discharged, contradictions: [], assumptions }; +}; + +export interface CandidateScore { + verdict: LocateCandidate['verdict']; + notes: readonly string[]; +} + +/** Could this observation be a render of this place? */ +export const scorePlace = ( + place: Place, + observation: Observation +): CandidateScore => { + const notes: string[] = []; + let conditional = false; + let contradicted = false; + + for (const binding of place.bindings) { + const requirement = requirementOf(binding.axis); + if (!requirement.modeled) continue; + const verdict = chainVerdict(observation, requirement); + + if (binding.state === 'established' && verdict.state === 'refuted') { + contradicted = true; + notes.push( + `the place establishes '${binding.axis}' but the complete observed ` + + 'chain lacks it' + ); + } + if (binding.state === 'refuted' && verdict.state === 'established') { + conditional = true; + notes.push( + `the place refutes '${binding.axis}' within the file's JSX root — ` + + 'the observed establishment is possible only if that scoped ' + + 'assumption fails beyond the file' + ); + } + } + + return { + verdict: contradicted + ? 'contradicted' + : conditional + ? 'conditional' + : 'consistent', + notes, + }; +}; + +export interface ModeImplication { + point: ScenarioPoint; + conflicts: readonly string[]; +} + +/** + * The mode an observed chain implies, validated against the snapshot's + * declared modes — an undeclared value is a conflict, never a coordinate. + */ +export const impliedModeOf = ( + observation: Observation, + modeDomain: DimensionDomain | undefined +): ModeImplication => { + const seen = new Set(); + for (const element of observation.ancestors ?? []) { + for (const [name, value] of Object.entries(element.attributes ?? {})) { + const match = MODE_SELECTOR.exec(`[${name}=${value}]`); + if (match !== null) seen.add(match[1]); + } + } + if (seen.size === 0) return { point: {}, conflicts: [] }; + if (seen.size > 1) { + return { + point: {}, + conflicts: [ + `the observed chain carries more than one data-color-mode value ` + + `(${Array.from(seen).join(', ')}) — no single mode is implied`, + ], + }; + } + const value = Array.from(seen)[0]; + const declared = + modeDomain?.kind === 'finite' && + modeDomain.values.some((mode) => mode === value); + if (!declared) { + return { + point: {}, + conflicts: [ + `observed data-color-mode '${value}' is not a declared mode of ` + + 'this snapshot', + ], + }; + } + return { point: { [MODE_DIMENSION]: value }, conflicts: [] }; +}; + +export interface ClassInversion { + point: ScenarioPoint; + conflicts: readonly string[]; + /** The observed classes this component's replay accounts for. */ + matched: readonly string[]; +} + +/** + * Invert the observed class list through the resolution replay: bindings are + * *proposed* from replay deltas and *verified* by replaying the combined + * point. A family of classes that fails replay yields conflicts, never a + * partially-trusted point. + */ +export const invertObservedClasses = ( + resolution: TargetResolution, + observed: readonly string[] +): ClassInversion => { + const base = resolution.component.className; + const family = observed.filter( + (name) => name === base || name.startsWith(`${base}--`) + ); + const baseline = new Set(resolution.classes({})); + const conflicts: string[] = []; + const point: Record = {}; + + for (const [dimension, domain] of Object.entries(resolution.dimensions)) { + if (domain.kind !== 'finite') continue; + const implied = domain.values.filter((value) => { + const delta = resolution + .classes({ [dimension]: value }) + .filter((name) => !baseline.has(name)); + return delta.length > 0 && delta.every((name) => family.includes(name)); + }); + if (implied.length === 1) point[dimension] = implied[0]; + else if (implied.length > 1) { + conflicts.push( + `the observed classes imply more than one value for '${dimension}': ` + + implied.map(String).join(', ') + ); + } + } + + const replay = new Set(resolution.classes(point)); + const missing = Array.from(replay).filter((name) => !family.includes(name)); + const unexplained = family.filter((name) => !replay.has(name)); + if (missing.length > 0 || unexplained.length > 0) { + conflicts.push( + 'the observed classes do not replay from any single scenario point' + + (missing.length > 0 ? ` — missing: ${missing.join(', ')}` : '') + + (unexplained.length > 0 + ? ` — unexplained: ${unexplained.join(', ')}` + : '') + ); + return { point: {}, conflicts, matched: family }; + } + return { point, conflicts, matched: family }; +}; diff --git a/packages/oracle/src/places/resolve.ts b/packages/oracle/src/places/resolve.ts new file mode 100644 index 00000000..8e9dc912 --- /dev/null +++ b/packages/oracle/src/places/resolve.ts @@ -0,0 +1,66 @@ +import { posix } from 'node:path'; + +import type { ManifestImportFact } from '../host/animus/manifest-types'; +import type { ComponentRecord } from '../providers/identity'; + +/** + * Attribute one JSX tag in one file to an extracted component (seam S4). A + * bare binding that matches two components must never resolve to an + * arbitrary winner (`IdentityProvider.resolveTarget`'s contract) — but a + * relative import specifier names one file, so the path can decide what the + * binding cannot. Anything still ambiguous is returned as such for the + * analysis to surface, never silently dropped. + */ + +export type TagResolution = + | { kind: 'resolved'; component: ComponentRecord } + | { + kind: 'ambiguous'; + candidates: readonly ComponentRecord[]; + specifier?: string; + } + | { kind: 'unknown' }; + +/** Does this component's file answer to the resolved relative specifier? */ +const fileAnswersTo = (componentFile: string, resolved: string): boolean => + componentFile === resolved || + componentFile.startsWith(`${resolved}.`) || + componentFile.startsWith(`${resolved}/index.`); + +export const resolveComponentTag = ( + components: readonly ComponentRecord[], + imports: readonly ManifestImportFact[] | undefined, + file: string, + tag: string +): TagResolution => { + const local = components.find( + (component) => component.file === file && component.binding === tag + ); + if (local !== undefined) return { kind: 'resolved', component: local }; + + const entry = imports?.find((fact) => fact.local === tag); + const name = entry?.imported ?? tag; + const candidates = components.filter( + (component) => component.binding === name + ); + if (candidates.length === 0) return { kind: 'unknown' }; + if (candidates.length === 1) { + return { kind: 'resolved', component: candidates[0] }; + } + + const specifier = entry?.source; + if (specifier !== undefined && specifier.startsWith('.')) { + const target = posix.join(posix.dirname(file), specifier); + const byPath = candidates.filter((component) => + fileAnswersTo(component.file, target) + ); + if (byPath.length === 1) { + return { kind: 'resolved', component: byPath[0] }; + } + } + return { + kind: 'ambiguous', + candidates, + ...(specifier === undefined ? {} : { specifier }), + }; +}; diff --git a/packages/oracle/src/places/snapshot.ts b/packages/oracle/src/places/snapshot.ts index 331e6f51..2ae945ee 100644 --- a/packages/oracle/src/places/snapshot.ts +++ b/packages/oracle/src/places/snapshot.ts @@ -1,8 +1,13 @@ import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { createAnimusHost } from '../host/animus/host'; -import { loadAnimusArtifacts } from '../host/animus/loader'; +import { + COMMIT_FILE, + loadAnimusArtifacts, + MANIFEST_FILE, + STYLESHEET_FILE, +} from '../host/animus/loader'; import { asManifest } from '../host/animus/manifest-types'; import { readSourceStructure } from './source'; @@ -35,10 +40,24 @@ export interface Snapshot { * generations, not an error: the working tree no longer matches the * program the artifacts describe, and mixing them would produce facts * about a program that never existed. + * + * The check holds over time (PLACES.md §6): the file is re-read on every + * call, so a warm process answers about the file as it is NOW — an edit + * after load flips the answer to `diverged`, and a revert restores it. */ structureOf(file: string): StructureResult; + /** + * Is the loaded artifact set still the one on disk? A rebuilt `.animus` + * directory means this snapshot describes a dead generation — a warm + * consumer must refuse or reload, never keep answering from it. + */ + revalidate(): SnapshotFreshness; } +export type SnapshotFreshness = + | { fresh: true } + | { fresh: false; changed: readonly string[] }; + export type StructureResult = | { ok: true; read: SourceRead } | { @@ -125,6 +144,20 @@ const usageDivergences = ( return divergences; }; +/** The artifact files whose bytes define the loaded generation. */ +const ARTIFACT_FILES = [MANIFEST_FILE, STYLESHEET_FILE, COMMIT_FILE] as const; + +const artifactBytes = ( + dir: string +): ReadonlyMap => { + const bytes = new Map(); + for (const name of ARTIFACT_FILES) { + const path = join(dir, name); + bytes.set(name, existsSync(path) ? readFileSync(path, 'utf8') : undefined); + } + return bytes; +}; + export const loadSnapshot = ( artifactsDir: string, options: SnapshotOptions = {} @@ -136,54 +169,71 @@ export const loadSnapshot = ( }); const manifest = asManifest(input.manifest); const sourceRoot = resolve(options.sourceRoot ?? dirname(artifactsDir)); - const structures = new Map(); + const loadedBytes = artifactBytes(artifactsDir); + const structures = new Map< + string, + { sourceText: string; result: StructureResult } + >(); const fileFacts = (file: string): ManifestFileFacts | undefined => manifest.fileFacts?.[file]; const structureOf = (file: string): StructureResult => { - const cached = structures.get(file); - if (cached !== undefined) return cached; - const facts = fileFacts(file); - let result: StructureResult; if (facts === undefined) { - result = { + return { ok: false, reason: 'not-in-snapshot', detail: `${file} has no fileFacts in this snapshot — it was not part of ` + `the analyzed program (generation ${host.program.label ?? host.program.hash})`, }; - } else { - const path = resolve(sourceRoot, file); - if (!existsSync(path)) { - result = { - ok: false, - reason: 'source-missing', - detail: `${file} resolves to ${path}, which does not exist`, - }; - } else { - const read = readSourceStructure(file, readFileSync(path, 'utf8')); - const divergences = usageDivergences(read, facts.usage ?? []); - result = - divergences.length === 0 - ? { ok: true, read } - : { - ok: false, - reason: 'diverged', - detail: - `${file} no longer corresponds to this snapshot's ` + - 'generation — rebuild the artifacts or ask about the ' + - 'committed source', - divergences, - }; - } } - structures.set(file, result); + const path = resolve(sourceRoot, file); + if (!existsSync(path)) { + structures.delete(file); + return { + ok: false, + reason: 'source-missing', + detail: `${file} resolves to ${path}, which does not exist`, + }; + } + // The cache is keyed by content, not by time: the file is re-read on + // every call, and only an unchanged read reuses the parsed result. This + // is what keeps a warm process honest — correspondence is a property of + // the file as it is now, not of the session's first look at it. + const sourceText = readFileSync(path, 'utf8'); + const cached = structures.get(file); + if (cached !== undefined && cached.sourceText === sourceText) { + return cached.result; + } + + const read = readSourceStructure(file, sourceText); + const divergences = usageDivergences(read, facts.usage ?? []); + const result: StructureResult = + divergences.length === 0 + ? { ok: true, read } + : { + ok: false, + reason: 'diverged', + detail: + `${file} no longer corresponds to this snapshot's ` + + 'generation — rebuild the artifacts or ask about the ' + + 'committed source', + divergences, + }; + structures.set(file, { sourceText, result }); return result; }; + const revalidate = (): SnapshotFreshness => { + const current = artifactBytes(artifactsDir); + const changed = ARTIFACT_FILES.filter( + (name) => current.get(name) !== loadedBytes.get(name) + ); + return changed.length === 0 ? { fresh: true } : { fresh: false, changed }; + }; + return { host, manifest, @@ -192,5 +242,6 @@ export const loadSnapshot = ( fileFacts, files: () => Object.keys(manifest.fileFacts ?? {}), structureOf, + revalidate, }; };