@kontourai/lookout 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,67 @@
1
1
  # @kontourai/lookout
2
2
 
3
- A small **source registry** and **non-throwing drift-check runner** built on
4
- [Traverse](https://github.com/kontourai/traverse) snapshots. Lookout answers one
5
- question per registered source, cheaply and honestly: *did this source change
6
- since we last looked?*
3
+ **Cheap, honest drift detection for content you re-check over time — "did this source change since we last looked?"**
7
4
 
8
- It composes Traverse for fetching and snapshot storage, provides deterministic
9
- proposal diffing, and authors provenance-bearing Survey inputs. It does **not**
10
- extract fields, project to Surface, review claims, notify, crawl, or schedule.
5
+ A small **source registry** and **non-throwing drift-check runner** built on
6
+ [Forage](https://github.com/kontourai/forage) snapshots. It composes Forage for
7
+ fetching and snapshot storage, provides deterministic proposal diffing, and
8
+ emits neutral, typed drift — its own vocabulary, in its own dependency-free
9
+ package. It does **not** implement acquisition or extraction, author trust-layer
10
+ records, project to Surface, review claims, notify, crawl, or schedule.
11
11
  Operational failures are returned as typed results.
12
12
 
13
+ ## Why it's different
14
+
15
+ The naive way to answer "did it change?" is to re-crawl the source and diff the
16
+ bytes. That's expensive (full re-download every time), noisy (a changed ad or
17
+ timestamp reads as "changed"), and fragile (a thrown error mid-run looks the same
18
+ as "no change"). Lookout is the opposite on all three:
19
+
20
+ | | Re-crawl + byte-diff | `lookout` |
21
+ |---|---|---|
22
+ | Cost | full re-download every check | **conditional `304`** — often no download at all |
23
+ | Signal | raw bytes (ads/timestamps = "changed") | **proposal-identity diff** — "a *new entity appeared*" vs "a byte moved" |
24
+ | Honesty | a crash or false-`304` silently reads as "unchanged" | **typed results, never throws**; Forage scopes validators to the exact prior resource and Lookout verifies the returned capture identity |
25
+ | Output | your problem to shape | **neutral, typed drift** — events already Hachure-evidence-shaped, ready for a consumer to lift into a trust bundle |
26
+
27
+ So the point isn't "diffing" — it's *cheap + honest + semantic + review-ready*
28
+ change detection, so a periodic re-check surfaces **only the real delta** (this
29
+ provider is new, that listing changed) instead of re-reviewing everything.
30
+
31
+ ## Where it sits
32
+
33
+ One layer in a four-verb stack; each repo owns one verb and is usable alone:
34
+
35
+ - **[forage](https://github.com/kontourai/forage)** — CRAWL: fetch / frontier / SSRF-safe egress / snapshots
36
+ - **[traverse](https://github.com/kontourai/traverse)** — EXTRACT: content + schema → reviewable proposals
37
+ - **lookout** — CHANGE: did this registered source drift since last look?
38
+ - **survey** — the SHAPE: what reviewed truth looks like (claims / review / resolution)
39
+
40
+ Lookout composes Forage's `/fetch` surface for cheap `304`-aware re-checks,
41
+ and Traverse's `ExtractionProposal` identity for the semantic diff. Dependency
42
+ arrows point only downward — no cycles. Lookout itself depends on nothing in the
43
+ trust layer: its events are already Hachure-evidence-shaped (`snapshotRef` /
44
+ `locator` / `excerpt` / `fieldPath`), so a consumer or product lifts them into a
45
+ Hachure `TrustBundle` via `@kontourai/surface`'s `TrustBundleBuilder` — the same
46
+ pattern Traverse uses to match Survey's shape without importing Survey.
47
+
48
+ **SSRF-safe egress.** Registered source URLs are operator- / aggregator-supplied
49
+ and not fully trusted, so lookout routes its default fetch transport through
50
+ [forage](https://github.com/kontourai/forage)'s SSRF-pinned guarded fetch
51
+ (`@kontourai/forage/egress`). A registered source pointing at a private,
52
+ link-local, loopback, or cloud-metadata host (e.g. `169.254.169.254`) is refused
53
+ before any connection — a drift check can never be turned into an SSRF vector.
54
+ A caller that injects its own `fetchSource` or `fetchOptions.fetch` (e.g. tests)
55
+ owns its transport and opts out of the default guard.
56
+
13
57
  ## Requirements
14
58
 
15
59
  - Node.js `>= 22`.
16
- - `@kontourai/traverse` `>= 0.14.1`. This is a hard floor, not a preference:
17
- trustworthy `unchanged-304` classification depends on the validator-scoping fix
18
- from [traverse#49](https://github.com/kontourai/traverse/issues/49), first
19
- released in `0.14.1`. Lookout exact-pins `0.14.1`. On an earlier Traverse a
20
- conditional request could reuse a prior snapshot's validators against a
21
- different URL and report a **false** `304`, so do not downgrade.
60
+ - `@kontourai/forage` `>= 0.4.0`. Exact durable-reference replay requires the
61
+ resolver first released in `0.4.0`; trustworthy `unchanged-304`
62
+ classification also depends on Forage's validator-scoped revalidation.
63
+ Lookout exact-pins Traverse separately for schema and extraction-proposal
64
+ contracts.
22
65
 
23
66
  ## Registry
24
67
 
@@ -36,6 +79,13 @@ override with the library path argument or the CLI `--registry` flag):
36
79
  "targetSchema": [{ "path": "title", "type": "string", "required": true }],
37
80
  "cadenceHint": "daily",
38
81
  "renderPolicy": "never"
82
+ },
83
+ {
84
+ "id": "published-results",
85
+ "kind": "structured-file",
86
+ "format": "yaml",
87
+ "url": "https://raw.githubusercontent.com/example/project/0123456789abcdef0123456789abcdef01234567/results.yml",
88
+ "cadenceHint": "weekly"
39
89
  }
40
90
  ]
41
91
  }
@@ -46,11 +96,17 @@ Fields per source:
46
96
  | Field | Meaning |
47
97
  | --- | --- |
48
98
  | `id` | Non-empty, unique within the document. Used for exact lookup and snapshot identity. |
49
- | `kind` | `web-page` or `api-record`. |
99
+ | `kind` | `web-page`, `api-record`, or `structured-file`. |
50
100
  | `url` | Absolute HTTP(S) URL. |
51
- | `targetSchema` | Traverse `TargetFieldSchema[]`. Stored inert at L1 (no extraction yet). |
101
+ | `format` | Required only for `structured-file`: `yaml`, `json`, or `csv`. Lookout does not parse it. |
102
+ | `targetSchema` | Required for `web-page` and `api-record`: Traverse `TargetFieldSchema[]`. Stored inert at L1 (no extraction yet). |
52
103
  | `cadenceHint` | Non-empty string; advisory only — Lookout does not schedule. |
53
- | `renderPolicy` | `never` \| `on-shell-warning` \| `always`. **Inert** at L1 (never mapped to a fetch/render policy). |
104
+ | `renderPolicy` | Required for `web-page` and `api-record`: `never` \| `on-shell-warning` \| `always`. **Inert** at L1. |
105
+
106
+ Structured-file entries retain raw fetched bytes and use the same guarded fetch,
107
+ snapshot, comparison, and replay path as every other source. Parsing and domain
108
+ normalization stay downstream. Prefer immutable, commit-pinned artifact URLs so
109
+ an upstream branch move cannot silently redefine the cited source location.
54
110
 
55
111
  Validation reports **every** deterministic issue at once (with index/id context)
56
112
  and never reads the network. Lookup is exact-id; listing preserves file order —
@@ -59,42 +115,74 @@ no merging, discovery, or remote registries.
59
115
  ## Check results
60
116
 
61
117
  A check classifies each source into exactly one of four kinds. Every result
62
- carries `sourceId`, the registered `sourceUrl`, `checkedAt`, and Traverse
63
- `warnings`.
118
+ carries `sourceId`, the registered `sourceUrl`, `checkedAt`, and Forage fetch
119
+ warnings.
64
120
 
65
121
  | `kind` | When | Extra fields |
66
122
  | --- | --- | --- |
67
123
  | `unchanged-304` | A validator-backed conditional request returned `304`; zero body transfer; the prior snapshot is not re-persisted. | `snapshotRef` |
68
124
  | `unchanged-hash` | A full body was fetched and persisted, but its sha256 `bodyHash` equals the prior, **and** it came from the same resource URL — established by Lookout's own comparison. | `priorSnapshotRef`, `currentSnapshotRef` |
69
125
  | `changed` | The fresh body differs from the prior (`changeBasis: "hash"`), **or** it is the first successful observation (`changeBasis: "initial"`, `priorSnapshotRef: null`). | `priorSnapshotRef` (nullable), `currentSnapshotRef`, `changeBasis` |
70
- | `error` | Any operational failure — contained so the runner never rejects. | `origin` (`traverse` \| `lookout`), `error` |
126
+ | `error` | Any operational failure — contained so the runner never rejects. | `origin` (`forage` \| `lookout`), `error` |
71
127
 
72
128
  `unchanged-hash` asserts same-resource continuity, so it requires **both** an
73
129
  identical `bodyHash` **and** the same resource URL as the prior snapshot. If a
74
130
  source has moved — the prior snapshot's URL differs from this fetch's — the
75
131
  result is `changed` even when the bytes are byte-identical, and the fresh
76
132
  snapshot is persisted as the new baseline. (The `unchanged-304` path is already
77
- resource-scoped by Traverse's validators, so only the hash path needs this
133
+ resource-scoped by Forage's validators, so only the hash path needs this
78
134
  guard.)
79
135
 
80
- `error` results preserve provenance: `origin: "traverse"` carries Traverse's
136
+ `error` results preserve provenance: `origin: "forage"` carries Forage's
81
137
  discriminated `FetchError` verbatim (its `kind`, and `status` when present);
82
138
  `origin: "lookout"` carries a typed `kind` — `prior-read`, `persistence`,
83
139
  `dependency-contract`, or `unexpected`.
84
140
 
85
141
  Snapshot references (`snapshotRef` / `priorSnapshotRef` / `currentSnapshotRef`)
86
- are portable logical refs from Traverse's `buildSnapshotSourceRef` — never
87
- filesystem paths. Snapshots are stored via Traverse's filesystem store, rooted by
142
+ are portable logical refs from Forage's `buildSnapshotSourceRef` — never
143
+ filesystem paths. Snapshots are stored via Forage's filesystem store, rooted by
88
144
  default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
89
145
  `--snapshot-root` flag or by injecting a store in library use). Lookout adds no
90
- custom filenames or retention.
146
+ custom filenames or retention. `resolveLookoutSnapshot()` replays one exact
147
+ reference through an injected store or Lookout snapshot root, authenticates its
148
+ body and replay metadata, and never fetches. References emitted before Forage's
149
+ replay-envelope digest remain resolvable with `integrity: "body-and-identity"`;
150
+ new references report `integrity: "snapshot-envelope"`.
151
+
152
+ ## Schema coverage
153
+
154
+ Drift isn't only "the bytes changed" — a source can reformat so that a field
155
+ your schema *declares* silently stops being produced. The byte/proposal diffs
156
+ above are temporal (they need a prior and say nothing on a first look), so they
157
+ can't catch this. `checkSchemaCoverage` is the static complement:
158
+
159
+ ```ts
160
+ import { checkSchemaCoverage } from "@kontourai/lookout";
161
+
162
+ if (source.kind === "structured-file") throw new Error("structured sources are parsed downstream");
163
+ const { covered, gaps } = checkSchemaCoverage(source.targetSchema, proposals);
164
+ // covered: declared paths at least one proposal produced (schema order)
165
+ // gaps: declared paths that produced none, each with its `required` flag
166
+ for (const gap of gaps) {
167
+ // escalate a missing required field harder than a missing optional one
168
+ }
169
+ ```
170
+
171
+ It reduces one declared `TargetFieldSchema[]` and one produced
172
+ `ExtractionProposal[]` set into `{ covered, gaps }` by exact
173
+ `proposal.fieldPath === field.path`. It is pure and total — no prior, no
174
+ network, no throw — and answers on the very first observation. It measures the
175
+ produced set you pass (it runs no post-verification filtering itself), reports
176
+ only against the *declared* surface (an undeclared proposal path is neither
177
+ covered nor a gap), and preserves schema order. Escalation — warning, review
178
+ item, or hard failure — is the consumer's call.
91
179
 
92
180
  ## CLI
93
181
 
94
182
  ```
95
183
  lookout check <id> [--registry <path>] [--snapshot-root <path>]
96
184
  lookout check --all [--registry <path>] [--snapshot-root <path>]
97
- lookout emit-survey <id> --observation <path|-> [--registry <path>] [--observation-root <path>]
185
+ lookout emit-drift <id> --observation <path|-> [--registry <path>] [--observation-root <path>]
98
186
  ```
99
187
 
100
188
  - Emits **exactly one compact JSON object per checked source, per stdout line**
@@ -110,14 +198,18 @@ lookout emit-survey <id> --observation <path|-> [--registry <path>] [--observati
110
198
  This exit/output contract is what makes `lookout check --all` safe to drive from
111
199
  an external scheduler.
112
200
 
113
- `emit-survey` is a separate composable command: its input is a JSON object with
201
+ `emit-drift` is a separate composable command: its input is a JSON object with
114
202
  `observation` (a `ProposalSetObservation`) and its matching `check` anchor.
115
203
  Extraction is supplied by the caller. The first successful input commits a
116
- baseline fact and returns `surveyInput: null`; a genuine later change returns
117
- one unreviewed SurveyInput batch. State defaults to
118
- `.kontourai/lookout/observations`, uses immutable digest-addressed records and
119
- an atomic per-source pointer, and retains the latest two valid observations.
120
- Consumers pass the batch to Survey when they want Surface projection.
204
+ baseline fact and returns `priorObservationId: null` with empty `events`; a
205
+ genuine later change returns non-empty `events` and `facts` plus a
206
+ `priorObservationId` pointing at the prior observation it was diffed against.
207
+ State defaults to `.kontourai/lookout/observations`, uses immutable
208
+ digest-addressed records and an atomic per-source pointer, and retains the
209
+ latest two valid observations. The emitted `events` are already
210
+ Hachure-evidence-shaped; a consumer or product lifts them into a Hachure
211
+ `TrustBundle` with `@kontourai/surface`'s `TrustBundleBuilder` when it wants
212
+ Surface projection — lookout itself authors nothing in the trust layer.
121
213
  Store paths refuse symbolic links. An existing source lock is not automatically
122
214
  broken: after confirming no writer is active, an operator may remove an
123
215
  abandoned `.lock` file and retry.
@@ -137,16 +229,72 @@ const results = await runner.checkAll(registry.list());
137
229
  ```
138
230
 
139
231
  `createCheckRunner` accepts injected seams — `store`, `fetchSource` (defaults to
140
- Traverse), `fetchOptions`, and `clock` — so checks run with no live network or
141
- timers in tests.
232
+ Forage's SSRF-guarded fetcher), `fetchOptions`, and
233
+ `clock` — so checks run with no live network or timers in tests. Injecting either
234
+ `fetchSource` or `fetchOptions.fetch` overrides the default guarded transport.
235
+
236
+ ## Observe changed sources without re-extracting unchanged ones
237
+
238
+ `createObserveExtractDiff` is an optional library composition for callers that
239
+ want one typed observation per check. Supply acquisition, extraction, and
240
+ recording capabilities; Lookout does not choose a content-preparation method,
241
+ provider, or observation database.
242
+
243
+ ```ts
244
+ import { createObserveExtractDiff } from "@kontourai/lookout";
245
+
246
+ const composition = createObserveExtractDiff({
247
+ acquisition: { check: runner.check },
248
+ extraction: {
249
+ async extract({ source, snapshotRef }) {
250
+ // Resolve `snapshotRef`, prepare content, and invoke a caller-selected
251
+ // extraction implementation. Return its public Traverse result.
252
+ return extractChangedSource(source, snapshotRef);
253
+ },
254
+ },
255
+ recorder: {
256
+ async record(observation) {
257
+ // Caller-owned continuity and durable storage.
258
+ return saveObservation(observation);
259
+ },
260
+ },
261
+ });
262
+
263
+ const result = await composition.observe(source);
264
+ ```
265
+
266
+ `unchanged-304` and `unchanged-hash` are recorded as `unchanged` and never call
267
+ the extraction capability, so they make zero preparation and provider calls.
268
+ Changed observations retain source and snapshot references, Traverse's prepared
269
+ artifact identity, the proposal set, and the current/prior observation
270
+ identities returned by the recorder. `partial`, `provider-failure`, mixed
271
+ `partial-provider-failure`, and `extraction-failure` remain distinct typed
272
+ outcomes. Provider failures are reduced to provider-neutral `kind` and
273
+ `retryable` classifications; provider names, messages, native diagnostics,
274
+ free-form extraction warnings, raw responses, and thrown-error text are not
275
+ copied into the durable observation.
276
+ A first changed observation
277
+ has `priorObservationId: null`; it is a baseline observation, not a fabricated
278
+ list of additions or removals.
279
+
280
+ The composition validates that the check identifies the requested registered
281
+ source and that Traverse validates the prepared artifact whose snapshot anchor
282
+ matches the current check. This prevents mismatched capability results from
283
+ being recorded as one observation. Proposal excerpts, source URLs, and
284
+ acquisition check warnings can still contain sensitive source material; callers
285
+ must apply their own retention and access policy before durable storage.
286
+
287
+ The existing `createCheckRunner` and `createDriftEmitter` entrypoints remain
288
+ available. Use `createDriftEmitter` when the caller wants deterministic
289
+ proposal-diff events with its own identity callbacks.
142
290
 
143
291
  ## Non-goals
144
292
 
145
- Extraction, Surface projection, notifications, crawling, review/escalation or
146
- authority policy, rendered-fetch wiring
147
- (`renderPolicy` is inert pending traverse#50), and scheduling. Traverse retains
148
- all fetch politeness, robots, redirects, retries, timeouts, headers, user-agent,
149
- and rendered-fetch behavior.
293
+ Acquisition and extraction implementations, Surface projection, notifications,
294
+ crawling, review/escalation or authority policy, rendered-fetch wiring
295
+ (`renderPolicy` is inert), and scheduling. Forage retains all fetch politeness,
296
+ robots, redirects, retries, timeouts, headers, user-agent, and rendered-fetch
297
+ behavior.
150
298
 
151
299
  ## Development
152
300
 
@@ -1,4 +1,4 @@
1
- import type { FetchError } from "@kontourai/traverse/fetch";
1
+ import type { FetchError } from "@kontourai/forage/fetch";
2
2
  export interface CheckResultCommon {
3
3
  sourceId: string;
4
4
  sourceUrl: string;
@@ -23,7 +23,7 @@ export interface ChangedResult extends CheckResultCommon {
23
23
  export type LookoutErrorKind = "prior-read" | "persistence" | "dependency-contract" | "unexpected";
24
24
  export interface ErrorResult extends CheckResultCommon {
25
25
  kind: "error";
26
- origin: "traverse" | "lookout";
26
+ origin: "forage" | "lookout";
27
27
  error: FetchError | {
28
28
  kind: LookoutErrorKind;
29
29
  message: string;
@@ -1,4 +1,4 @@
1
- import type { FetchResult, FetchSourceOptions, SnapshotStore, SourceConfig } from "@kontourai/traverse/fetch";
1
+ import type { FetchResult, FetchSourceOptions, SnapshotStore, SourceConfig } from "@kontourai/forage/fetch";
2
2
  import type { CheckResult } from "./check-result.js";
3
3
  import type { ProviderResolver } from "./provider-resolution.js";
4
4
  import type { LookoutSource } from "./registry.js";
@@ -1,7 +1,20 @@
1
- import { buildSnapshotSourceRef, fetchSource as traverseFetchSource, } from "@kontourai/traverse/fetch";
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { buildSnapshotSourceRef, fetchSource as forageFetchSource, } from "@kontourai/forage/fetch";
3
+ /**
4
+ * The egress policy for lookout's registered-source fetches. Registered source
5
+ * URLs are operator- / aggregator-supplied and not fully trusted, so a source
6
+ * pointing at a private, link-local, loopback, or cloud-metadata host must be
7
+ * refused before any connection — a drift check can never be turned into an
8
+ * SSRF vector. `forage`'s `fetchSource` builds its own SSRF-pinned guarded
9
+ * transport from this policy whenever the caller doesn't inject a custom
10
+ * `fetch` (e.g. tests), so this is the single shared policy literal — never
11
+ * scattered per call site, never `{ guarded: false }`.
12
+ */
13
+ const GUARDED_EGRESS = { guarded: true };
2
14
  export function createCheckRunner(options) {
3
- const fetchImpl = options.fetchSource ?? traverseFetchSource;
15
+ const fetchImpl = options.fetchSource ?? forageFetchSource;
4
16
  const clock = options.clock ?? (() => new Date().toISOString());
17
+ const fetchOptions = { ...options.fetchOptions };
5
18
  async function check(source) {
6
19
  const common = () => ({
7
20
  sourceId: source.id,
@@ -18,17 +31,17 @@ export function createCheckRunner(options) {
18
31
  }
19
32
  let fetched;
20
33
  try {
21
- fetched = await fetchImpl({ id: source.id, url: source.url, revalidate: true }, { ...options.fetchOptions, store: options.store });
34
+ fetched = await fetchImpl({ id: source.id, url: source.url, egress: GUARDED_EGRESS }, { ...fetchOptions, store: options.store });
22
35
  }
23
36
  catch (error) {
24
37
  return lookoutError(common(), "unexpected", error);
25
38
  }
26
39
  const base = { ...common(), warnings: Array.isArray(fetched?.warnings) ? [...fetched.warnings] : [] };
27
40
  if (!isFetchResult(fetched)) {
28
- return lookoutError(base, "dependency-contract", "Traverse returned neither exactly one snapshot nor exactly one error");
41
+ return lookoutError(base, "dependency-contract", "Forage returned neither exactly one snapshot nor exactly one error");
29
42
  }
30
43
  if (fetched.error) {
31
- return { ...base, kind: "error", origin: "traverse", error: fetched.error };
44
+ return { ...base, kind: "error", origin: "forage", error: fetched.error };
32
45
  }
33
46
  // Defense-in-depth: even if a future guard gap let a malformed snapshot
34
47
  // through, any stray throw in classification/ref-building becomes a typed
@@ -36,7 +49,22 @@ export function createCheckRunner(options) {
36
49
  try {
37
50
  const snapshot = fetched.snapshot;
38
51
  if (snapshot.notModified === true) {
39
- return { ...base, kind: "unchanged-304", snapshotRef: buildSnapshotSourceRef(snapshot) };
52
+ const snapshotBodyEncoding = bodyEncoding(snapshot);
53
+ const priorBodyEncoding = prior === undefined ? undefined : bodyEncoding(prior);
54
+ if (prior === undefined ||
55
+ snapshotBodyEncoding === undefined ||
56
+ snapshotBodyEncoding !== priorBodyEncoding ||
57
+ snapshot.sourceId !== prior.sourceId ||
58
+ snapshot.url !== prior.url ||
59
+ snapshot.status !== prior.status ||
60
+ snapshot.fetchedAt !== prior.fetchedAt ||
61
+ snapshot.bodyHash !== prior.bodyHash ||
62
+ !isDeepStrictEqual(snapshot.headers, prior.headers) ||
63
+ !isDeepStrictEqual(snapshot.redirects, prior.redirects) ||
64
+ snapshot.rendered !== prior.rendered) {
65
+ return lookoutError(base, "dependency-contract", "Forage returned a 304 snapshot without the matching prior capture");
66
+ }
67
+ return { ...base, kind: "unchanged-304", snapshotRef: buildSnapshotSourceRef(prior) };
40
68
  }
41
69
  try {
42
70
  await options.store.put(snapshot);
@@ -76,6 +104,14 @@ export function createCheckRunner(options) {
76
104
  }
77
105
  return { check, checkAll };
78
106
  }
107
+ function bodyEncoding(snapshot) {
108
+ const descriptor = Object.getOwnPropertyDescriptor(snapshot, "body");
109
+ if (descriptor === undefined || !("value" in descriptor))
110
+ return undefined;
111
+ if (typeof descriptor.value === "string")
112
+ return "utf8";
113
+ return descriptor.value instanceof Uint8Array ? "bytes" : undefined;
114
+ }
79
115
  function isFetchResult(value) {
80
116
  if (typeof value !== "object" || value === null)
81
117
  return false;
package/dist/src/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Writable } from "node:stream";
2
2
  import { type CheckRunner } from "./check-runner.js";
3
3
  import { type LookoutRegistry } from "./registry.js";
4
- import { type EmissionResult } from "./survey-emission.js";
4
+ import { type DriftResult } from "./drift-emission.js";
5
5
  export interface RunCliOptions {
6
6
  argv?: string[];
7
7
  stdout?: Pick<Writable, "write">;
@@ -9,6 +9,6 @@ export interface RunCliOptions {
9
9
  loadRegistry?: (path?: string) => Promise<LookoutRegistry>;
10
10
  runner?: CheckRunner;
11
11
  readObservation?: (path: string) => Promise<unknown>;
12
- emitSurvey?: (sourceId: string, value: unknown, registry: LookoutRegistry, observationRoot?: string) => Promise<EmissionResult>;
12
+ emitDrift?: (sourceId: string, value: unknown, registry: LookoutRegistry, observationRoot?: string) => Promise<DriftResult>;
13
13
  }
14
14
  export declare function runCli(options?: RunCliOptions): Promise<number>;
package/dist/src/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createCheckRunner } from "./check-runner.js";
3
3
  import { loadRegistry } from "./registry.js";
4
4
  import { createLookoutSnapshotStore } from "./snapshot-store.js";
5
5
  import { createObservationStore } from "./observation-store.js";
6
- import { createSurveyEmitter } from "./survey-emission.js";
6
+ import { createDriftEmitter } from "./drift-emission.js";
7
7
  export async function runCli(options = {}) {
8
8
  const argv = options.argv ?? process.argv.slice(2);
9
9
  const stdout = options.stdout ?? process.stdout;
@@ -21,7 +21,7 @@ export async function runCli(options = {}) {
21
21
  stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
22
22
  return 1;
23
23
  }
24
- if (parsed.command === "emit-survey") {
24
+ if (parsed.command === "emit-drift") {
25
25
  const source = registry.get(parsed.id);
26
26
  if (!source) {
27
27
  stderr.write(`Unknown source id: ${parsed.id}\n`);
@@ -35,7 +35,7 @@ export async function runCli(options = {}) {
35
35
  stderr.write(`Could not read observation: ${error instanceof Error ? error.message : String(error)}\n`);
36
36
  return 1;
37
37
  }
38
- const result = await (options.emitSurvey ?? emitSurvey)(parsed.id, value, registry, parsed.observationRoot);
38
+ const result = await (options.emitDrift ?? emitDrift)(parsed.id, value, registry, parsed.observationRoot);
39
39
  if (!result.ok) {
40
40
  stderr.write(`${result.error.kind}: ${result.error.message}\n`);
41
41
  return 1;
@@ -61,10 +61,10 @@ export async function runCli(options = {}) {
61
61
  return 0;
62
62
  }
63
63
  function parseArgs(argv) {
64
- if (argv[0] === "emit-survey")
64
+ if (argv[0] === "emit-drift")
65
65
  return parseEmitArgs(argv);
66
66
  if (argv[0] !== "check")
67
- return "Usage: lookout check <id>|--all [--registry path] [--snapshot-root path]\n lookout emit-survey <id> --observation <path|-> [--registry path] [--observation-root path]";
67
+ return "Usage: lookout check <id>|--all [--registry path] [--snapshot-root path]\n lookout emit-drift <id> --observation <path|-> [--registry path] [--observation-root path]";
68
68
  const parsed = { command: "check", all: false };
69
69
  for (let index = 1; index < argv.length; index += 1) {
70
70
  const arg = argv[index];
@@ -125,10 +125,10 @@ function parseEmitArgs(argv) {
125
125
  id = arg;
126
126
  }
127
127
  if (!id)
128
- return "emit-survey requires a source id";
128
+ return "emit-drift requires a source id";
129
129
  if (!observationPath)
130
- return "emit-survey requires --observation <path|->";
131
- return { command: "emit-survey", id, observationPath, registryPath, observationRoot };
130
+ return "emit-drift requires --observation <path|->";
131
+ return { command: "emit-drift", id, observationPath, registryPath, observationRoot };
132
132
  }
133
133
  async function readObservation(file) {
134
134
  const maxBytes = 1024 * 1024;
@@ -164,10 +164,10 @@ function cliEntities(observation) {
164
164
  }
165
165
  return [...grouped].map(([key, proposals]) => ({ key, proposals }));
166
166
  }
167
- async function emitSurvey(sourceId, value, registry, observationRoot) {
167
+ async function emitDrift(sourceId, value, registry, observationRoot) {
168
168
  if (!value || typeof value !== "object" || Array.isArray(value))
169
169
  return { ok: false, error: { kind: "invalid-input", message: "Observation document must be an object" } };
170
170
  const document = value;
171
171
  const source = registry.get(sourceId);
172
- return createSurveyEmitter({ store: createObservationStore({ root: observationRoot }) }).emit({ source, current: document.observation, check: document.check, callbacks: { selectEntities: cliEntities, entityIdentity: (entity) => entity.key, proposalsFor: (entity) => entity.proposals, fieldIdentity: (_entity, proposal) => proposal.fieldPath } });
172
+ return createDriftEmitter({ store: createObservationStore({ root: observationRoot }) }).emit({ source, current: document.observation, check: document.check, callbacks: { selectEntities: cliEntities, entityIdentity: (entity) => entity.key, proposalsFor: (entity) => entity.proposals, fieldIdentity: (_entity, proposal) => proposal.fieldPath } });
173
173
  }
@@ -0,0 +1,58 @@
1
+ import type { ExtractionProposal, TargetFieldSchema } from "@kontourai/traverse";
2
+ /**
3
+ * A declared schema field that the supplied observation did not cover: the
4
+ * extractor produced **zero** proposals whose `fieldPath` equals this field's
5
+ * declared `path`. `required` mirrors the schema field's own `required` flag
6
+ * (absent → `false`) so a consumer can escalate a missing required field harder
7
+ * than a missing optional one.
8
+ *
9
+ * The gap keys the declared field under `fieldPath` (not `path`) deliberately:
10
+ * it is the same string as the schema field's `path`, but named to line up with
11
+ * `ExtractionProposal.fieldPath` so a consumer can correlate a gap directly
12
+ * against the proposals it was measured over. `covered` needs no such wrapper —
13
+ * it carries neither the `required` flag nor a correlation need — so it stays a
14
+ * bare `string[]` of declared paths.
15
+ */
16
+ export interface SchemaCoverageGap {
17
+ readonly fieldPath: string;
18
+ readonly required: boolean;
19
+ }
20
+ /**
21
+ * The result of checking one extraction observation against the declared
22
+ * schema. `covered` lists, in schema order, every declared `path` that at least
23
+ * one proposal produced; `gaps` lists, in schema order, every declared `path`
24
+ * that produced none. The two are disjoint and together cover the declared
25
+ * schema. `gaps` never mentions a proposal fieldPath that is absent from the
26
+ * schema — this is a coverage check of the *declared* surface, not an
27
+ * unexpected-field detector.
28
+ */
29
+ export interface SchemaCoverageResult {
30
+ readonly covered: readonly string[];
31
+ readonly gaps: readonly SchemaCoverageGap[];
32
+ }
33
+ /**
34
+ * Static schema-coverage check: which declared schema fields did this single
35
+ * extraction observation fail to produce at all?
36
+ *
37
+ * This is the *static*, first-observation-capable complement to the temporal
38
+ * proposal diff (`diffProposalSets`). The diff answers "did the produced
39
+ * proposals change since we last looked?" and, by construction, says nothing on
40
+ * a first observation. Coverage answers a question that has an answer on the
41
+ * very first look and needs no prior: "did the source drift — reformat, rename a
42
+ * heading, reorder a table — such that a field the schema *declares* silently
43
+ * stopped being produced?". A declared field with no proposal is exactly that
44
+ * signal: a layout-drift regression that would otherwise surface only as a
45
+ * silent gap in the output.
46
+ *
47
+ * Membership is by exact `proposal.fieldPath === field.path`. Matching against
48
+ * the produced-proposal set (not any post-verification survivor set) is
49
+ * deliberate: a field the extractor *did* produce but a downstream step later
50
+ * dropped is explained by that step's own diagnostic and is not a coverage gap
51
+ * — the extractor covered it. Callers that want coverage measured against a
52
+ * post-verification set simply pass that set as `proposals`.
53
+ *
54
+ * Pure and total: no injected callbacks, no network, no throw. Declared fields
55
+ * are evaluated in schema order; a schema that repeats a `path` reports it once
56
+ * per occurrence (the schema, not this check, owns declared-field uniqueness).
57
+ */
58
+ export declare function checkSchemaCoverage(schema: readonly TargetFieldSchema[], proposals: readonly ExtractionProposal[]): SchemaCoverageResult;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Static schema-coverage check: which declared schema fields did this single
3
+ * extraction observation fail to produce at all?
4
+ *
5
+ * This is the *static*, first-observation-capable complement to the temporal
6
+ * proposal diff (`diffProposalSets`). The diff answers "did the produced
7
+ * proposals change since we last looked?" and, by construction, says nothing on
8
+ * a first observation. Coverage answers a question that has an answer on the
9
+ * very first look and needs no prior: "did the source drift — reformat, rename a
10
+ * heading, reorder a table — such that a field the schema *declares* silently
11
+ * stopped being produced?". A declared field with no proposal is exactly that
12
+ * signal: a layout-drift regression that would otherwise surface only as a
13
+ * silent gap in the output.
14
+ *
15
+ * Membership is by exact `proposal.fieldPath === field.path`. Matching against
16
+ * the produced-proposal set (not any post-verification survivor set) is
17
+ * deliberate: a field the extractor *did* produce but a downstream step later
18
+ * dropped is explained by that step's own diagnostic and is not a coverage gap
19
+ * — the extractor covered it. Callers that want coverage measured against a
20
+ * post-verification set simply pass that set as `proposals`.
21
+ *
22
+ * Pure and total: no injected callbacks, no network, no throw. Declared fields
23
+ * are evaluated in schema order; a schema that repeats a `path` reports it once
24
+ * per occurrence (the schema, not this check, owns declared-field uniqueness).
25
+ */
26
+ export function checkSchemaCoverage(schema, proposals) {
27
+ const producedPaths = new Set(proposals.map((proposal) => proposal.fieldPath));
28
+ const covered = [];
29
+ const gaps = [];
30
+ for (const field of schema) {
31
+ if (producedPaths.has(field.path)) {
32
+ covered.push(field.path);
33
+ }
34
+ else {
35
+ gaps.push({ fieldPath: field.path, required: field.required === true });
36
+ }
37
+ }
38
+ return { covered, gaps };
39
+ }