@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 +186 -38
- package/dist/src/check-result.d.ts +2 -2
- package/dist/src/check-runner.d.ts +1 -1
- package/dist/src/check-runner.js +42 -6
- package/dist/src/cli.d.ts +2 -2
- package/dist/src/cli.js +10 -10
- package/dist/src/coverage.d.ts +58 -0
- package/dist/src/coverage.js +39 -0
- package/dist/src/{survey-emission.d.ts → drift-emission.d.ts} +16 -19
- package/dist/src/drift-emission.js +71 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.js +4 -2
- package/dist/src/observe-extract-diff.d.ts +90 -0
- package/dist/src/observe-extract-diff.js +154 -0
- package/dist/src/registry.d.ts +16 -4
- package/dist/src/registry.js +107 -41
- package/dist/src/snapshot-store.d.ts +11 -1
- package/dist/src/snapshot-store.js +18 -1
- package/package.json +4 -4
- package/dist/src/survey-emission.js +0 -138
package/README.md
CHANGED
|
@@ -1,24 +1,67 @@
|
|
|
1
1
|
# @kontourai/lookout
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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/
|
|
17
|
-
trustworthy `unchanged-304`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 `
|
|
99
|
+
| `kind` | `web-page`, `api-record`, or `structured-file`. |
|
|
50
100
|
| `url` | Absolute HTTP(S) URL. |
|
|
51
|
-
| `
|
|
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
|
|
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
|
|
63
|
-
|
|
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` (`
|
|
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
|
|
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: "
|
|
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
|
|
87
|
-
filesystem paths. Snapshots are stored via
|
|
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-
|
|
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-
|
|
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 `
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
authority policy, rendered-fetch wiring
|
|
147
|
-
(`renderPolicy` is inert
|
|
148
|
-
|
|
149
|
-
|
|
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/
|
|
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: "
|
|
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/
|
|
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";
|
package/dist/src/check-runner.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
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 ??
|
|
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,
|
|
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", "
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
128
|
+
return "emit-drift requires a source id";
|
|
129
129
|
if (!observationPath)
|
|
130
|
-
return "emit-
|
|
131
|
-
return { command: "emit-
|
|
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
|
|
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
|
|
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
|
+
}
|