@egildo/provenance 0.0.0-stage → 0.6.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Egildo Tagliareni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,172 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
4
+ <img src="assets/logo.svg" alt="Provenance" width="360">
5
+ </picture>
6
+ </p>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ <p align="center">
9
+ <strong>Every byte of rendered output, traced back to the exact file, version, and byte range that produced it.</strong>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <img alt="version" src="https://img.shields.io/badge/version-0.5.0-blue">
14
+ <img alt="node" src="https://img.shields.io/badge/node-%E2%89%A522-339933?logo=node.js&logoColor=white">
15
+ <img alt="license" src="https://img.shields.io/badge/license-MIT-blue">
16
+ <img alt="status" src="https://img.shields.io/badge/status-kernel%20%2B%20editing-orange">
17
+ </p>
18
+
19
+ ---
20
+
21
+ A rendered document is made of many files. By the time a page appears in a browser, it has read
22
+ an include tree, a configuration cascade, stylesheets, fonts, images, and whatever plugins pulled
23
+ in along the way. Two questions follow every render: **what did it actually load**, and **which
24
+ files must change on disk** when someone edits what they see.
25
+
26
+ Provenance is one structure that answers both. For a single rendered document, it knows every
27
+ source involved, how those sources depend on each other, where each rendered thing came from, and
28
+ how to stage and write changes back — without ever touching a filesystem itself.
29
+
30
+ ```ts
31
+ import { openSession } from "@egildo/provenance";
32
+ import { createNodeHost } from "@egildo/provenance/node";
33
+ import { css } from "@egildo/provenance/css";
34
+ import { html } from "@egildo/provenance/html";
35
+
36
+ const session = await openSession({
37
+ host: createNodeHost(),
38
+ entry: "/path/to/index.html",
39
+ handlers: [html, css],
40
+ });
41
+
42
+ for (const source of session.sources()) {
43
+ console.log(source.id, source.state, source.location);
44
+ }
45
+
46
+ session.onChange(change => console.log(change));
47
+ ```
48
+
49
+ ## Why
50
+
51
+ Most tools that need this answer it twice, with bookkeeping that can't talk to itself. BelType —
52
+ the project Provenance was extracted from — kept a list of files read, a separate list of URLs
53
+ loaded, two near-identical file watchers, and one hand-rolled rule mapping URLs back to files.
54
+ Each piece answered one question. None of them could answer the other, because they shared no
55
+ coordinates.
56
+
57
+ Provenance gives every fact — an edge, a stamp, an edit — one coordinate system: a source, a
58
+ version, and a byte range. Anything it knows can be joined against anything else it knows.
59
+
60
+ ## What it does
61
+
62
+ - **Tracks sources of any kind.** Markdown, HTML, CSS, configuration, images, fonts — and
63
+ whatever a handler teaches it. Binary files are leaves: addressable, never parsed.
64
+ - **Records dependencies twice.** *Declared* edges come from parsing; *observed* edges come from
65
+ what a runtime actually fetched. The two are kept apart on purpose — the gap between them is
66
+ the product, not noise to smooth over.
67
+ - **Addresses rendered output.** Any rendered node can point back to the exact source, version,
68
+ and byte range that produced it.
69
+ - **Stages edits.** Changes are held against a base version, previewed in memory, and written to
70
+ disk together. What gets written is exactly what the preview showed.
71
+
72
+ It runs in any JavaScript realm — Node, a browser tab, a worker — because it performs **no I/O of
73
+ its own**. Everything outside the call (reading, writing, watching, path arithmetic) is asked of a
74
+ **host** the embedder supplies. A Node host ships as its own entry point; nothing else in the
75
+ library imports from `node:`, and a test enforces that rather than trusting convention.
76
+
77
+ ## How it decides things
78
+
79
+ Provenance is opinionated on purpose. Nine design principles govern it, each one stated as
80
+ something it refuses to do:
81
+
82
+ | Principle | Forbids |
83
+ |---|---|
84
+ | **One address space** | A fact with no address. A second coordinate system past the handler boundary. |
85
+ | **Handlers declare; Provenance decides** | A handler that reads, resolves, caches, watches, or writes. |
86
+ | **Derived facts are pure functions of versions** | Any `invalidate()` call, anywhere. A cache hit that skips re-registering its probes. |
87
+ | **Declared and observed stay apart** | An observation that overwrites a declared edge. A disagreement smoothed away. |
88
+ | **Source is the stored reality; renders are projections** | Writing Provenance's own bookkeeping into a source file. |
89
+ | **Nothing touches disk before commit** | Any write outside commit. A staged edit expressed against another staged edit's result. |
90
+ | **Edits are splices** | A printer or serializer that regenerates a source from a tree. |
91
+ | **Languages are plugins; nesting is delegation** | Language knowledge in the core. A handler parsing another language's region itself. |
92
+ | **Supply, not meaning** | A `node:` import outside the Node host. A host that returns a verdict instead of a fact. |
93
+
94
+ The full reasoning — with the incidents each rule was cut from — is in
95
+ [the design principles](docs/principles.md).
96
+
97
+ ## Status
98
+
99
+ **The kernel is built, and editing's first slice. Observation is not.**
100
+
101
+ A session finds every source reachable from an entry document and its roots, records each
102
+ declared dependency against the exact bytes that asked for it, keeps broken references visible
103
+ until they heal, and reports what changed as files change. See
104
+ [the kernel spec](specs/source-graph-kernel/spec.md) for exactly what that covers, and
105
+ [the changelog](CHANGELOG.md) for what shipped in each release.
106
+
107
+ Since 0.5.0 it also **writes back**: a tool stages an edit to a value, sees a preview, and commits it;
108
+ the edit lands in exactly the bytes of that value, every other byte untouched, and survives the
109
+ file changing on disk in the meantime ([the design](docs/write-back.md),
110
+ [the editing spec](specs/editing/spec.md)). Writing is granted, never ambient. Since 0.6.0 it
111
+ also inserts and deletes entries ([insert-delete](specs/insert-delete/spec.md)), and an edit staged on a file that a
112
+ colleague is mid-way through saving waits for it to parse again. Format plug-ins come from outside —
113
+ Cascata's YAML and JSON writers are the first.
114
+
115
+ What's next: **stamps** (outputs that carry their source address, so an edit on a rendered page
116
+ finds its way back), **Markdown units** and a **CSS writer**, then **observation** — see
117
+ [the vision's "What arrives first"](docs/vision.md#what-arrives-first).
118
+
119
+ ## Install
120
+
121
+ ```bash
122
+ npm install @egildo/provenance
123
+ ```
124
+
125
+ Its tests prove write-back end to end with Cascata, a development dependency, `@egildo/cascata`.
126
+
127
+ Requires Node 22 or later (22.18+ to run the tests from source, for type stripping).
128
+
129
+ ## Entry points
130
+
131
+ | Import | What it gives you |
132
+ |---|---|
133
+ | `@egildo/provenance` | `openSession` and the core types |
134
+ | `@egildo/provenance/html` | An HTML handler, on [parse5](https://github.com/inikulin/parse5) |
135
+ | `@egildo/provenance/css` | A CSS handler, on [PostCSS](https://postcss.org/) |
136
+ | `@egildo/provenance/node` | The Node host |
137
+ | `@egildo/provenance/memory` | An in-memory host, for any realm — tests, browsers, workers |
138
+
139
+ Every export is listed in [the public API contract](specs/source-graph-kernel/contracts/public-api.md).
140
+
141
+ ## Who it's for
142
+
143
+ - **BelType**, first — replacing its file list, URL list, two watchers, and eventually its
144
+ editing server's write path.
145
+ - **Cascata**, by shape and not by import — a read pass's host satisfies Cascata's `Host`
146
+ interface structurally, so Cascata's configuration files become session roots without Cascata
147
+ ever importing Provenance.
148
+ - **The CSS tools**, which build on Provenance's addresses and its edit model.
149
+ - **Any tool** that renders documents from files and needs to know what it read, what the runtime
150
+ loaded, and how to write back.
151
+
152
+ What it deliberately stays out of — rendering, transforming, reading meaning, serving, history —
153
+ is in [the vision's "What it is not"](docs/vision.md#what-it-is-not), each exclusion argued for.
154
+
155
+ ## Documentation
156
+
157
+ - **[Vision](docs/vision.md)** — what Provenance is for, who it serves, what it refuses to be.
158
+ - **[Design principles](docs/principles.md)** — how it decides things, each rule with what it forbids.
159
+ - **[Glossary](docs/glossary.md)** — every christened term, and which meaning of "provenance" is ours.
160
+ - **[Design synthesis](docs/design-synthesis.md)** — the day-zero design the vision was drawn from.
161
+ - **[Kernel spec](specs/source-graph-kernel/spec.md)** and **[read-passes spec](specs/read-passes/spec.md)** — what's built, precisely.
162
+
163
+ ## Development
164
+
165
+ ```bash
166
+ npm test # type-checks and runs the tests
167
+ npm run build # writes dist/
168
+ ```
169
+
170
+ ## License
171
+
172
+ [MIT](LICENSE)
package/dist/css.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { Handler } from "./index.ts";
2
+ export declare const css: Handler;
package/dist/css.js ADDED
@@ -0,0 +1,77 @@
1
+ // The CSS handler: `url()`, `image-set()` strings and `@import`, found with PostCSS
2
+ // (specs/source-graph-kernel/research.md, CSS handler). Embedded regions are not its concern.
3
+ import safeParse from "postcss-safe-parser";
4
+ import valueParser from "postcss-value-parser";
5
+ /** Undoes CSS escapes: `\` and one to six hex digits (and one following space), or `\` and a character. */
6
+ function unescape(text) {
7
+ return text.replace(/\\(?:([0-9a-f]{1,6})[ \t\n]?|([^\n]))/gi, (_, hex, char) => hex ? String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff) || 0xfffd) : (char ?? ""));
8
+ }
9
+ /** The request a `url()` argument or a string holds, with its range in the whole text. */
10
+ function literal(node, offset, kind) {
11
+ if (node.type === "word") {
12
+ return { start: offset + node.sourceIndex, end: offset + node.sourceEndIndex, request: unescape(node.value), kind };
13
+ }
14
+ if (node.type === "string") {
15
+ const end = node.unclosed ? node.sourceEndIndex : node.sourceEndIndex - 1;
16
+ return { start: offset + node.sourceIndex + 1, end: offset + end, request: unescape(node.value), kind };
17
+ }
18
+ return undefined;
19
+ }
20
+ const isNamed = (node, ...names) => node.type === "function" && names.includes(node.value.toLowerCase());
21
+ /** Every `url()`, and every string directly inside `image-set()`, in a value placed at `offset`. */
22
+ function urls(value, offset, requests) {
23
+ valueParser(value).walk(node => {
24
+ if (node.type !== "function")
25
+ return;
26
+ if (isNamed(node, "url")) {
27
+ const found = node.nodes[0] && literal(node.nodes[0], offset, "candidate");
28
+ if (found)
29
+ requests.push(found);
30
+ return false;
31
+ }
32
+ if (isNamed(node, "image-set", "-webkit-image-set")) {
33
+ for (const child of node.nodes) {
34
+ const found = child.type === "string" && literal(child, offset, "candidate");
35
+ if (found)
36
+ requests.push(found);
37
+ }
38
+ }
39
+ return undefined;
40
+ });
41
+ }
42
+ /** `@import`'s URL: required unless a condition (media, `supports()`) follows; a `layer` is not one. */
43
+ function importRequest(params, offset) {
44
+ const nodes = valueParser(params).nodes.filter(n => n.type !== "space" && n.type !== "comment");
45
+ const [first, ...rest] = nodes;
46
+ if (!first)
47
+ return undefined;
48
+ const conditioned = rest.some(n => !(n.type === "word" && n.value.toLowerCase() === "layer") && !isNamed(n, "layer"));
49
+ const kind = conditioned ? "candidate" : "requires";
50
+ if (first.type === "string")
51
+ return literal(first, offset, kind);
52
+ if (isNamed(first, "url") && first.type === "function" && first.nodes[0])
53
+ return literal(first.nodes[0], offset, kind);
54
+ return undefined;
55
+ }
56
+ export const css = {
57
+ claims: location => location.toLowerCase().endsWith(".css"),
58
+ analyze(text) {
59
+ const requests = [];
60
+ safeParse(text).walk(node => {
61
+ const start = node.source?.start?.offset;
62
+ if (start === undefined)
63
+ return;
64
+ if (node.type === "decl") {
65
+ const value = node.raws.value?.raw ?? node.value;
66
+ urls(value, start + node.prop.length + (node.raws.between ?? "").length, requests);
67
+ }
68
+ else if (node.type === "atrule" && node.name.toLowerCase() === "import") {
69
+ const params = node.raws.params?.raw ?? node.params;
70
+ const found = importRequest(params, start + 1 + node.name.length + (node.raws.afterName ?? "").length);
71
+ if (found)
72
+ requests.push(found);
73
+ }
74
+ });
75
+ return { requests };
76
+ },
77
+ };
package/dist/html.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { Handler } from "./index.ts";
2
+ export declare const html: Handler;
package/dist/html.js ADDED
@@ -0,0 +1,128 @@
1
+ // The HTML handler: the attributes that make a browser load something, found with parse5
2
+ // (specs/source-graph-kernel/contracts/public-api.md, Handlers). `<style>` and `style="…"` are
3
+ // CSS regions, not this handler's language, and wait for regions.
4
+ import { parse } from "parse5";
5
+ /** The value's range inside an attribute's `name="value"` span, or undefined for a bare name. */
6
+ function valueRange(text, span) {
7
+ const raw = text.slice(span.startOffset, span.endOffset);
8
+ const head = /^[^\s=]+\s*=\s*(["']?)\s*/.exec(raw);
9
+ if (!head)
10
+ return undefined;
11
+ const quoted = head[1] !== "" && raw.endsWith(head[1]) && raw.length > head[0].length;
12
+ const value = raw.slice(head[0].length, raw.length - (quoted ? 1 : 0));
13
+ return { start: span.startOffset + head[0].length, end: span.startOffset + head[0].length + value.trimEnd().length };
14
+ }
15
+ /**
16
+ * The URL ranges in a `srcset`, per the HTML standard's "parse a srcset attribute": a URL is a
17
+ * run of non-whitespace; trailing commas end it; descriptors run to a comma outside parentheses.
18
+ */
19
+ function srcsetUrls(value) {
20
+ const urls = [];
21
+ const space = /[\t\n\f\r ]/;
22
+ let i = 0;
23
+ while (i < value.length) {
24
+ while (i < value.length && (space.test(value[i]) || value[i] === ","))
25
+ i++;
26
+ if (i >= value.length)
27
+ break;
28
+ const start = i;
29
+ while (i < value.length && !space.test(value[i]))
30
+ i++;
31
+ let end = i;
32
+ if (value[end - 1] === ",") {
33
+ while (end > start && value[end - 1] === ",")
34
+ end--;
35
+ }
36
+ else {
37
+ let depth = 0;
38
+ while (i < value.length && !(value[i] === "," && depth === 0)) {
39
+ if (value[i] === "(")
40
+ depth++;
41
+ else if (value[i] === ")" && depth > 0)
42
+ depth--;
43
+ i++;
44
+ }
45
+ }
46
+ if (end > start)
47
+ urls.push({ start, end });
48
+ }
49
+ return urls;
50
+ }
51
+ /** Which attributes of an element load something, and how surely. */
52
+ function loads(element) {
53
+ const attr = (name) => element.attrs.find(a => a.name === name && !a.prefix)?.value;
54
+ const lazy = attr("loading")?.toLowerCase() === "lazy";
55
+ switch (element.tagName) {
56
+ case "link": {
57
+ const rel = (attr("rel") ?? "").toLowerCase().split(/\s+/);
58
+ if (rel.includes("stylesheet")) {
59
+ return [["href", attr("media") !== undefined || rel.includes("alternate") ? "candidate" : "requires"]];
60
+ }
61
+ return ["icon", "preload", "modulepreload", "prefetch"].some(r => rel.includes(r)) ? [["href", "candidate"]] : [];
62
+ }
63
+ case "img":
64
+ return [["src", lazy ? "candidate" : "requires"], ["srcset", "candidate", true]];
65
+ case "iframe":
66
+ return [["src", lazy ? "candidate" : "requires"]];
67
+ case "source":
68
+ return [["src", "candidate"], ["srcset", "candidate", true]];
69
+ case "video":
70
+ return [["src", "candidate"], ["poster", "candidate"]];
71
+ case "audio":
72
+ return [["src", "candidate"]];
73
+ case "object":
74
+ return [["data", "requires"]];
75
+ case "script":
76
+ return [["src", "requires"]];
77
+ case "use":
78
+ return [["href", "requires"], ["xlink:href", "requires"]];
79
+ default:
80
+ return [];
81
+ }
82
+ }
83
+ export const html = {
84
+ claims: location => /\.html?$/i.test(location),
85
+ analyze(text) {
86
+ const requests = [];
87
+ let base;
88
+ const visit = (node) => {
89
+ if ("tagName" in node) {
90
+ if (node.tagName === "base" && base === undefined) {
91
+ base = node.attrs.find(a => a.name === "href" && !a.prefix)?.value;
92
+ }
93
+ for (const [name, kind, isSrcset] of loads(node)) {
94
+ const attribute = node.attrs.find(a => (a.prefix ? `${a.prefix}:${a.name}` : a.name) === name);
95
+ const span = node.sourceCodeLocation?.attrs?.[name];
96
+ const range = attribute && span && valueRange(text, span);
97
+ if (!attribute || !range)
98
+ continue;
99
+ if (!isSrcset) {
100
+ requests.push({ ...range, request: attribute.value.trim(), kind });
101
+ continue;
102
+ }
103
+ // The ranges come from the text as written, the requests from the decoded value; they
104
+ // pair up unless an entity changed where a comma or a space falls.
105
+ const written = srcsetUrls(text.slice(range.start, range.end));
106
+ const decoded = srcsetUrls(attribute.value);
107
+ if (written.length !== decoded.length)
108
+ continue;
109
+ written.forEach((w, k) => {
110
+ requests.push({
111
+ start: range.start + w.start,
112
+ end: range.start + w.end,
113
+ request: attribute.value.slice(decoded[k].start, decoded[k].end),
114
+ kind,
115
+ });
116
+ });
117
+ }
118
+ }
119
+ if ("childNodes" in node)
120
+ for (const child of node.childNodes)
121
+ visit(child);
122
+ if ("content" in node)
123
+ visit(node.content);
124
+ };
125
+ visit(parse(text, { sourceCodeLocationInfo: true }));
126
+ return base === undefined ? { requests } : { requests, base };
127
+ },
128
+ };