wyrd-scribe 0.1.0
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 +201 -0
- package/README.md +114 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +215 -0
- package/dist/config.js.map +1 -0
- package/dist/frontmatter.d.ts +87 -0
- package/dist/frontmatter.js +302 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +69 -0
- package/dist/ledger.js +64 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lineage.d.ts +195 -0
- package/dist/lineage.js +194 -0
- package/dist/lineage.js.map +1 -0
- package/dist/main.d.ts +27 -0
- package/dist/main.js +169 -0
- package/dist/main.js.map +1 -0
- package/dist/refusal.d.ts +36 -0
- package/dist/refusal.js +24 -0
- package/dist/refusal.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +145 -0
- package/dist/server.js.map +1 -0
- package/dist/source.d.ts +76 -0
- package/dist/source.js +134 -0
- package/dist/source.js.map +1 -0
- package/dist/span.d.ts +117 -0
- package/dist/span.js +242 -0
- package/dist/span.js.map +1 -0
- package/dist/stamp.d.ts +297 -0
- package/dist/stamp.js +773 -0
- package/dist/stamp.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +60 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B2 — THE OPT-IN FRONTMATTER STAMP. Line-based, and it REFUSES rather than merging.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ NO YAML PARSER AND NO AST MERGE, AND THAT IS A SECURITY DECISION RATHER THAN A DEPENDENCY ONE.
|
|
5
|
+
* Parsing a stranger's frontmatter means running a YAML parser over untrusted text on the write
|
|
6
|
+
* path — YAML's own history of anchor-expansion and tag-resolution defects is why that is not a
|
|
7
|
+
* free operation — and then RE-EMITTING their document from an AST, which rewrites quoting,
|
|
8
|
+
* ordering, comments and anchors in a file the user did not ask us to reformat. D3's justification
|
|
9
|
+
* for writing into someone else's notes is that the act is benign and disclosed; silently
|
|
10
|
+
* reserialising their frontmatter is neither.
|
|
11
|
+
*
|
|
12
|
+
* So this module does the smallest thing that can be correct: it finds the block's boundary LINES
|
|
13
|
+
* and inserts ONE line. Anything it cannot do that way it REFUSES.
|
|
14
|
+
*
|
|
15
|
+
* ⚠⚠ THE PROJECTION CARRIES NO `page` KEY AT ALL, AND THE FULL REASON IS WORTH THE PARAGRAPH,
|
|
16
|
+
* because the shape it replaces looked correct and was measurably wrong.
|
|
17
|
+
*
|
|
18
|
+
* It used to omit `page.content` and keep `page.identity`, on the argument that only the DIGEST is
|
|
19
|
+
* self-referential: the frontmatter becomes part of the page bytes, so a projection containing the
|
|
20
|
+
* page's own digest would have to be computed from bytes that do not exist until it is inserted.
|
|
21
|
+
* That argument is sound and it does not go far enough — the IDENTITY is unrecordable here for a
|
|
22
|
+
* different reason, and the two together leave nothing of `page` behind.
|
|
23
|
+
*
|
|
24
|
+
* The projection is built from the DRAFT record, whose page path is the CALLER'S SPELLING, because
|
|
25
|
+
* the fence has not resolved the target yet and will not until the create. The ledger line is
|
|
26
|
+
* rebuilt afterwards from the fence's canonical `created.rel`. So a write through an in-grant
|
|
27
|
+
* junction — `Alias/answer.md` resolving to `Mage/answer.md` — put `Alias/answer.md` in the page
|
|
28
|
+
* and `Mage/answer.md` in the ledger: TWO PROVENANCE RECORDS FOR ONE WRITE, DISAGREEING ABOUT WHICH
|
|
29
|
+
* PAGE THIS IS. A reader with both cannot tell which is the page's identity, and the one embedded
|
|
30
|
+
* in the bytes is the one that travels with the file.
|
|
31
|
+
*
|
|
32
|
+
* The fix is not to defer the projection until the canonical path is known — that would mean
|
|
33
|
+
* composing the page bytes after creating the page, which inverts the ordering `stamp.ts`'s header
|
|
34
|
+
* spends its length defending. It is that A PAGE DOES NOT NEED TO NAME ITSELF INSIDE ITSELF. The
|
|
35
|
+
* projection's job is to carry the SOURCES — their identities, hashes and spans — plus the schema
|
|
36
|
+
* and vault fields that make those readable. Page identity has exactly one home, the ledger, which
|
|
37
|
+
* is where the canonical path is known and where a consumer already looks for it.
|
|
38
|
+
*
|
|
39
|
+
* ⚠ ONE KEY, `wyrd_lineage`, HOLDING SINGLE-LINE JSON. JSON is a subset of YAML 1.2, so a flow-style
|
|
40
|
+
* value on one line parses under any conformant reader without this module emitting block scalars,
|
|
41
|
+
* indentation or line folding — three things a hand-rolled emitter gets wrong and a diff makes
|
|
42
|
+
* unreadable. It also keeps the insertion to exactly one line, which is what makes the "find the
|
|
43
|
+
* closing fence and insert above it" rule sound.
|
|
44
|
+
*/
|
|
45
|
+
import type { LineageRecord } from './lineage.js';
|
|
46
|
+
import type { ScribeRefusal } from './refusal.js';
|
|
47
|
+
export declare const FRONTMATTER_KEY = "wyrd_lineage";
|
|
48
|
+
/**
|
|
49
|
+
* How far in we look for the closing `---`.
|
|
50
|
+
*
|
|
51
|
+
* ⚠ A BOUND, NOT A GUESS AT REAL DOCUMENTS. Without one, content that merely OPENS with `---` and
|
|
52
|
+
* never closes makes this scan the whole file to decide it has no frontmatter — an unbounded read
|
|
53
|
+
* of caller-supplied bytes on the write path, for a question about the document's first few lines.
|
|
54
|
+
* A block longer than this refuses; it does not get treated as absent, because treating it as
|
|
55
|
+
* absent would PREPEND a second block and produce a document with two.
|
|
56
|
+
*/
|
|
57
|
+
export declare const MAX_FRONTMATTER_LINES = 200;
|
|
58
|
+
/**
|
|
59
|
+
* The record as it appears in the page: the SOURCES, and the fields that make them readable.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ NO `page` MEMBER, AND ITS ABSENCE IS THE TYPE ENFORCING THE RULE ABOVE — a later session that
|
|
62
|
+
* decides the page should name itself has to widen this interface deliberately rather than by
|
|
63
|
+
* adding a line to `project`.
|
|
64
|
+
*/
|
|
65
|
+
export interface FrontmatterProjection {
|
|
66
|
+
readonly schema: string;
|
|
67
|
+
readonly event: string;
|
|
68
|
+
readonly event_id: string;
|
|
69
|
+
readonly recorded_at: string;
|
|
70
|
+
readonly writer: LineageRecord['writer'];
|
|
71
|
+
readonly vault: LineageRecord['vault'];
|
|
72
|
+
readonly sources: LineageRecord['sources'];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* ⚠ `event_id` IS CARRIED AND THE PAGE PATH IS NOT, WHICH IS THE ASYMMETRY WORTH NAMING. The id is
|
|
76
|
+
* the one field that JOINS this projection to its ledger line — without it a reader holding a
|
|
77
|
+
* stamped page and a ledger of many lines has only the timestamp to match on, and `ST29` exists
|
|
78
|
+
* because a timestamp does not distinguish two writes. It is server-minted per write, so it says
|
|
79
|
+
* nothing about the path and cannot disagree with the fence about anything.
|
|
80
|
+
*/
|
|
81
|
+
export declare function project(record: LineageRecord): FrontmatterProjection;
|
|
82
|
+
/**
|
|
83
|
+
* Insert the stamp, or refuse.
|
|
84
|
+
*
|
|
85
|
+
* `content` is the caller's page text; the return carries the bytes to write.
|
|
86
|
+
*/
|
|
87
|
+
export declare function stamp(content: string, projection: FrontmatterProjection): string | ScribeRefusal;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B2 — THE OPT-IN FRONTMATTER STAMP. Line-based, and it REFUSES rather than merging.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ NO YAML PARSER AND NO AST MERGE, AND THAT IS A SECURITY DECISION RATHER THAN A DEPENDENCY ONE.
|
|
5
|
+
* Parsing a stranger's frontmatter means running a YAML parser over untrusted text on the write
|
|
6
|
+
* path — YAML's own history of anchor-expansion and tag-resolution defects is why that is not a
|
|
7
|
+
* free operation — and then RE-EMITTING their document from an AST, which rewrites quoting,
|
|
8
|
+
* ordering, comments and anchors in a file the user did not ask us to reformat. D3's justification
|
|
9
|
+
* for writing into someone else's notes is that the act is benign and disclosed; silently
|
|
10
|
+
* reserialising their frontmatter is neither.
|
|
11
|
+
*
|
|
12
|
+
* So this module does the smallest thing that can be correct: it finds the block's boundary LINES
|
|
13
|
+
* and inserts ONE line. Anything it cannot do that way it REFUSES.
|
|
14
|
+
*
|
|
15
|
+
* ⚠⚠ THE PROJECTION CARRIES NO `page` KEY AT ALL, AND THE FULL REASON IS WORTH THE PARAGRAPH,
|
|
16
|
+
* because the shape it replaces looked correct and was measurably wrong.
|
|
17
|
+
*
|
|
18
|
+
* It used to omit `page.content` and keep `page.identity`, on the argument that only the DIGEST is
|
|
19
|
+
* self-referential: the frontmatter becomes part of the page bytes, so a projection containing the
|
|
20
|
+
* page's own digest would have to be computed from bytes that do not exist until it is inserted.
|
|
21
|
+
* That argument is sound and it does not go far enough — the IDENTITY is unrecordable here for a
|
|
22
|
+
* different reason, and the two together leave nothing of `page` behind.
|
|
23
|
+
*
|
|
24
|
+
* The projection is built from the DRAFT record, whose page path is the CALLER'S SPELLING, because
|
|
25
|
+
* the fence has not resolved the target yet and will not until the create. The ledger line is
|
|
26
|
+
* rebuilt afterwards from the fence's canonical `created.rel`. So a write through an in-grant
|
|
27
|
+
* junction — `Alias/answer.md` resolving to `Mage/answer.md` — put `Alias/answer.md` in the page
|
|
28
|
+
* and `Mage/answer.md` in the ledger: TWO PROVENANCE RECORDS FOR ONE WRITE, DISAGREEING ABOUT WHICH
|
|
29
|
+
* PAGE THIS IS. A reader with both cannot tell which is the page's identity, and the one embedded
|
|
30
|
+
* in the bytes is the one that travels with the file.
|
|
31
|
+
*
|
|
32
|
+
* The fix is not to defer the projection until the canonical path is known — that would mean
|
|
33
|
+
* composing the page bytes after creating the page, which inverts the ordering `stamp.ts`'s header
|
|
34
|
+
* spends its length defending. It is that A PAGE DOES NOT NEED TO NAME ITSELF INSIDE ITSELF. The
|
|
35
|
+
* projection's job is to carry the SOURCES — their identities, hashes and spans — plus the schema
|
|
36
|
+
* and vault fields that make those readable. Page identity has exactly one home, the ledger, which
|
|
37
|
+
* is where the canonical path is known and where a consumer already looks for it.
|
|
38
|
+
*
|
|
39
|
+
* ⚠ ONE KEY, `wyrd_lineage`, HOLDING SINGLE-LINE JSON. JSON is a subset of YAML 1.2, so a flow-style
|
|
40
|
+
* value on one line parses under any conformant reader without this module emitting block scalars,
|
|
41
|
+
* indentation or line folding — three things a hand-rolled emitter gets wrong and a diff makes
|
|
42
|
+
* unreadable. It also keeps the insertion to exactly one line, which is what makes the "find the
|
|
43
|
+
* closing fence and insert above it" rule sound.
|
|
44
|
+
*/
|
|
45
|
+
import { scribeRefuse } from './refusal.js';
|
|
46
|
+
export const FRONTMATTER_KEY = 'wyrd_lineage';
|
|
47
|
+
/**
|
|
48
|
+
* How far in we look for the closing `---`.
|
|
49
|
+
*
|
|
50
|
+
* ⚠ A BOUND, NOT A GUESS AT REAL DOCUMENTS. Without one, content that merely OPENS with `---` and
|
|
51
|
+
* never closes makes this scan the whole file to decide it has no frontmatter — an unbounded read
|
|
52
|
+
* of caller-supplied bytes on the write path, for a question about the document's first few lines.
|
|
53
|
+
* A block longer than this refuses; it does not get treated as absent, because treating it as
|
|
54
|
+
* absent would PREPEND a second block and produce a document with two.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_FRONTMATTER_LINES = 200;
|
|
57
|
+
/**
|
|
58
|
+
* ⚠ `event_id` IS CARRIED AND THE PAGE PATH IS NOT, WHICH IS THE ASYMMETRY WORTH NAMING. The id is
|
|
59
|
+
* the one field that JOINS this projection to its ledger line — without it a reader holding a
|
|
60
|
+
* stamped page and a ledger of many lines has only the timestamp to match on, and `ST29` exists
|
|
61
|
+
* because a timestamp does not distinguish two writes. It is server-minted per write, so it says
|
|
62
|
+
* nothing about the path and cannot disagree with the fence about anything.
|
|
63
|
+
*/
|
|
64
|
+
export function project(record) {
|
|
65
|
+
return {
|
|
66
|
+
schema: record.schema,
|
|
67
|
+
event: record.event,
|
|
68
|
+
event_id: record.event_id,
|
|
69
|
+
recorded_at: record.recorded_at,
|
|
70
|
+
writer: record.writer,
|
|
71
|
+
vault: record.vault,
|
|
72
|
+
sources: record.sources
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* ⚠⚠ THE LINE SCAN IS OVER `\n` AND TOLERATES `\r`, because a document authored on Windows has
|
|
77
|
+
* CRLF endings and a scan that only knows `\n` sees the closing fence as `"---\r"`, fails to match
|
|
78
|
+
* it, and refuses `FRONTMATTER_INVALID` on a perfectly ordinary file. Trimming only the trailing
|
|
79
|
+
* `\r` — never other whitespace — keeps the match exact in every other respect: ` ---` is NOT a
|
|
80
|
+
* fence in YAML and must not be treated as one.
|
|
81
|
+
*/
|
|
82
|
+
function isFence(line) {
|
|
83
|
+
return (line.endsWith('\r') ? line.slice(0, -1) : line) === '---';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The key a mapping line declares, or `null` if the line declares none AT THE TOP LEVEL.
|
|
87
|
+
*
|
|
88
|
+
* ⚠⚠ THE INDENTATION TEST IS THE ONLY THING SEPARATING A TOP-LEVEL KEY FROM A NESTED ONE, AND IT
|
|
89
|
+
* HAD TO BE MADE SO. The first version sliced the key WITHOUT trimming, so an indented
|
|
90
|
+
* ` wyrd_lineage:` yielded the string `" wyrd_lineage"`, which never equalled the key anyway —
|
|
91
|
+
* two guards where one was doing the work, and the mutation matrix measured exactly that: removing
|
|
92
|
+
* the indentation test left the suite green (`M14`, a real survivor). The name is trimmed first now,
|
|
93
|
+
* so the test is load-bearing and the mutant dies. A guard that is masked by an incidental
|
|
94
|
+
* mismatch is a guard nothing is checking.
|
|
95
|
+
*/
|
|
96
|
+
function keyOf(line) {
|
|
97
|
+
const body = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
98
|
+
// ⚠ Only a TOP-LEVEL key counts for the conflict check: an indented `wyrd_lineage:` is a member
|
|
99
|
+
// of someone else's mapping and is not ours to collide with. A leading space disqualifies it.
|
|
100
|
+
if (/^\s/.test(body))
|
|
101
|
+
return null;
|
|
102
|
+
const colon = body.indexOf(':');
|
|
103
|
+
if (colon <= 0)
|
|
104
|
+
return null;
|
|
105
|
+
return unquote(body.slice(0, colon).trim());
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* ⚠⚠ `"wyrd_lineage"`, `'wyrd_lineage'` AND `wyrd_lineage` ARE ONE KEY IN YAML, AND UNTIL THIS
|
|
109
|
+
* EXISTED THE CONFLICT CHECK SAW THREE. MEASURED 2026-09-03, not reasoned: stamping a page whose
|
|
110
|
+
* frontmatter carried `"wyrd_lineage": {...}` did not refuse — it WROTE, leaving a document with
|
|
111
|
+
* TWO top-level `wyrd_lineage` keys. That is invalid YAML, and the ordinary parser response is to
|
|
112
|
+
* take one and silently drop the other, so the user's existing provenance record is destroyed
|
|
113
|
+
* without a refusal, an error, or a visible mark. `FRONTMATTER_CONFLICT` exists for exactly that
|
|
114
|
+
* case and was being routed around by a quote character.
|
|
115
|
+
*
|
|
116
|
+
* ⚠ IT STRIPS A MATCHED PAIR AND NOTHING MORE, AND THE NARROWNESS IS THE DESIGN. This module's
|
|
117
|
+
* founding rule is that it does not run a YAML parser over a stranger's frontmatter on the write
|
|
118
|
+
* path, so full scalar semantics — escape sequences, multi-line keys, flow keys — are deliberately
|
|
119
|
+
* NOT handled here. What is handled is the spelling a stamp or a person actually produces.
|
|
120
|
+
*
|
|
121
|
+
* ⚠ THE FAILURE DIRECTION IS THE SAFE ONE EITHER WAY. An exotic spelling this does not recognise
|
|
122
|
+
* falls back to today's behaviour, which is the bug and no worse than it. There is no spelling on
|
|
123
|
+
* which this makes the module refuse a document it should have written: an unmatched or absent
|
|
124
|
+
* quote returns the name unchanged.
|
|
125
|
+
*/
|
|
126
|
+
function unquote(name) {
|
|
127
|
+
if (name.length < 2)
|
|
128
|
+
return name;
|
|
129
|
+
const first = name[0];
|
|
130
|
+
if ((first === '"' || first === "'") && name.endsWith(first)) {
|
|
131
|
+
return name.slice(1, -1);
|
|
132
|
+
}
|
|
133
|
+
return name;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* ⚠⚠ A FIRST-LINE `---` IS NOT ENOUGH TO CALL SOMETHING FRONTMATTER, and the version that believed
|
|
137
|
+
* it was mishandled ordinary documents in two directions.
|
|
138
|
+
*
|
|
139
|
+
* A Markdown document may legitimately OPEN with a horizontal rule. Under the old rule that first
|
|
140
|
+
* line was an opening fence, so:
|
|
141
|
+
*
|
|
142
|
+
* · a document opening with a rule and then prose was refused `FRONTMATTER_INVALID` — a
|
|
143
|
+
* perfectly ordinary file the user could never write through this server, with a message
|
|
144
|
+
* saying their frontmatter had no closing fence when they had written no frontmatter at all;
|
|
145
|
+
* · a document with a SECOND rule within the 200-line ceiling was worse, because it succeeded:
|
|
146
|
+
* the key was inserted between two horizontal rules, in the middle of the user's prose, as if
|
|
147
|
+
* the span between them had been a mapping.
|
|
148
|
+
*
|
|
149
|
+
* So the block between the fences must LOOK like YAML mapping lines. Every non-blank line is either
|
|
150
|
+
* `key:`-shaped at column 0, a `#` comment, or an indented continuation of the line above. Prose
|
|
151
|
+
* fails all three, and a document whose first line is a rule is then correctly treated as having no
|
|
152
|
+
* frontmatter — it gets a block PREPENDED, which is the outcome the user wanted.
|
|
153
|
+
*
|
|
154
|
+
* ⚠ THIS IS A RECOGNISER, NOT A YAML PARSER, AND THE DISTINCTION IS THE MODULE'S WHOLE DESIGN. It
|
|
155
|
+
* decides one question — does this look like a mapping block? — from line shapes alone, and it
|
|
156
|
+
* still never parses, never builds an AST and never re-emits. The header's argument against running
|
|
157
|
+
* a YAML parser over untrusted text on the write path is untouched.
|
|
158
|
+
*
|
|
159
|
+
* ⚠ IT ERRS TOWARD "NOT FRONTMATTER". A block this rejects is PREPENDED to rather than inserted
|
|
160
|
+
* into, so the failure mode of a false negative is a document with a stamp block above a rule —
|
|
161
|
+
* visible, correct YAML, and nothing of the user's rewritten. A false POSITIVE is the defect above:
|
|
162
|
+
* a key spliced into prose.
|
|
163
|
+
*/
|
|
164
|
+
function looksLikeMapping(lines, from, to) {
|
|
165
|
+
for (let index = from; index < to; index += 1) {
|
|
166
|
+
const raw = lines[index];
|
|
167
|
+
const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
168
|
+
if (body.trim().length === 0)
|
|
169
|
+
continue;
|
|
170
|
+
// An indented line continues whatever came before it — a nested mapping, a sequence member,
|
|
171
|
+
// a folded scalar. Its own shape is not this recogniser's business.
|
|
172
|
+
if (/^\s/.test(body))
|
|
173
|
+
continue;
|
|
174
|
+
if (body.startsWith('#'))
|
|
175
|
+
continue;
|
|
176
|
+
// ⚠ A KEY AT COLUMN 0, WITH THE COLON REQUIRED. `keyOf` already encodes exactly this shape
|
|
177
|
+
// for the conflict check, so it is reused rather than re-expressed — two spellings of "what
|
|
178
|
+
// is a top-level key" would be two things to keep in step.
|
|
179
|
+
if (keyOf(body) !== null)
|
|
180
|
+
continue;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The document's line ending, decided from the OPENING FENCE rather than from a scan.
|
|
187
|
+
*
|
|
188
|
+
* ⚠ A CRLF DOCUMENT MUST GET A CRLF KEY LINE. Inserting a lone LF into a file whose every other
|
|
189
|
+
* line ends CRLF produces a document with mixed endings — which git will report as a whole-file
|
|
190
|
+
* change on the next normalisation, and which some Windows editors render as one run-on line. The
|
|
191
|
+
* fence we are inserting above is the nearest evidence of what this document does, and it is the
|
|
192
|
+
* line our insertion sits against.
|
|
193
|
+
*/
|
|
194
|
+
function endingOf(line) {
|
|
195
|
+
return line.endsWith('\r') ? '\r' : '';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* ⚠⚠ THE PREPENDED BLOCK'S ENDING, AND EVERY PREPEND EXIT GOES THROUGH HERE. Three call sites
|
|
199
|
+
* used to build `` `---\n${keyLine}\n---\n${content}` `` inline, which put THREE lone LFs into a
|
|
200
|
+
* CRLF document — measured 2026-09-03 on all three, against the compiled build. That is the
|
|
201
|
+
* mixed-ending file `endingOf` exists to prevent, arrived at through the one path that had no
|
|
202
|
+
* fence to read: a prepend happens precisely when the document has no frontmatter block.
|
|
203
|
+
*
|
|
204
|
+
* ⚠ THE BLOCK HAS THREE SEPARATORS AND ALL THREE MATTER — after the opening fence, after the key
|
|
205
|
+
* line, and BETWEEN THE CLOSING FENCE AND THE USER'S FIRST LINE. A fix that changed only the ones
|
|
206
|
+
* inside the block would still leave a lone LF at the seam, which is the byte that actually abuts
|
|
207
|
+
* the user's text. That is why this returns the whole string rather than an ending to interpolate.
|
|
208
|
+
*
|
|
209
|
+
* ⚠ THE RULE IS THE FIRST PHYSICAL LINE'S ENDING, NOT A DOCUMENT-WIDE SURVEY, and the narrowness
|
|
210
|
+
* is the design. Our block touches the document at exactly one seam — above its first line — so
|
|
211
|
+
* that line is the only nearby evidence, and it is the same shape of rule the splice path already
|
|
212
|
+
* follows (take the ending from the line you are inserting against). A majority or last-ending
|
|
213
|
+
* heuristic would invent a global policy for a document that is already inconsistent, and would
|
|
214
|
+
* disagree with the splice path on that document — one module with two rules.
|
|
215
|
+
*
|
|
216
|
+
* ⚠ AN ALREADY-MIXED DOCUMENT STAYS MIXED, DELIBERATELY. We did not cause it, and this module's
|
|
217
|
+
* justification for writing into someone else's notes is that the act is minimal and disclosed;
|
|
218
|
+
* normalising their line endings is neither. Not one byte of `content` is rewritten here.
|
|
219
|
+
*
|
|
220
|
+
* ⚠ NO BREAK AT ALL — an empty document, or a single unterminated line — HAS NO STYLE TO PRESERVE,
|
|
221
|
+
* so it takes LF. Bare-CR (classic Mac) documents are out of scope and stay that way: `split('\n')`
|
|
222
|
+
* does not see them as multi-line anywhere in this module.
|
|
223
|
+
*/
|
|
224
|
+
function prepended(keyLine, content) {
|
|
225
|
+
const firstBreak = content.indexOf('\n');
|
|
226
|
+
const eol = firstBreak > 0 && content[firstBreak - 1] === '\r' ? '\r\n' : '\n';
|
|
227
|
+
return `---${eol}${keyLine}${eol}---${eol}${content}`;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Insert the stamp, or refuse.
|
|
231
|
+
*
|
|
232
|
+
* `content` is the caller's page text; the return carries the bytes to write.
|
|
233
|
+
*/
|
|
234
|
+
export function stamp(content, projection) {
|
|
235
|
+
const value = JSON.stringify(projection);
|
|
236
|
+
const keyLine = `${FRONTMATTER_KEY}: ${value}`;
|
|
237
|
+
// ⚠ THE OPENING FENCE MUST BE THE FIRST LINE, with nothing before it — not even a blank line.
|
|
238
|
+
// A `---` further down is a horizontal rule or a document separator in the body, and prepending
|
|
239
|
+
// a block above it is correct; treating it as an opening fence would insert our key into the
|
|
240
|
+
// middle of the user's prose.
|
|
241
|
+
const lines = content.split('\n');
|
|
242
|
+
const opens = lines.length > 0 && isFence(lines[0]);
|
|
243
|
+
if (!opens) {
|
|
244
|
+
return prepended(keyLine, content);
|
|
245
|
+
}
|
|
246
|
+
const ceiling = Math.min(lines.length, MAX_FRONTMATTER_LINES);
|
|
247
|
+
for (let index = 1; index < ceiling; index += 1) {
|
|
248
|
+
const line = lines[index];
|
|
249
|
+
if (isFence(line)) {
|
|
250
|
+
/**
|
|
251
|
+
* ⚠⚠ THE BLOCK IS RECOGNISED BEFORE IT IS WRITTEN INTO, AND A BLOCK THAT IS NOT A
|
|
252
|
+
* MAPPING IS NOT FRONTMATTER AT ALL. A pair of fences around PROSE is two horizontal
|
|
253
|
+
* rules, and splicing a key between them puts YAML into the middle of the user's
|
|
254
|
+
* document — the defect this check exists for.
|
|
255
|
+
*
|
|
256
|
+
* ⚠ SO IT PREPENDS RATHER THAN REFUSING, and the choice is the whole point. Refusing
|
|
257
|
+
* would leave an ordinary Markdown file — a rule, some prose, another rule —
|
|
258
|
+
* permanently unwritable through this server, which is the OTHER half of the same
|
|
259
|
+
* defect rather than an improvement on it. Treating the document as having no
|
|
260
|
+
* frontmatter is both true and useful: it gets a block on top and not one byte of the
|
|
261
|
+
* user's own text moves.
|
|
262
|
+
*
|
|
263
|
+
* ⚠ IT IS THE SAME ANSWER THE NO-CLOSING-FENCE PATH BELOW GIVES FOR THE SAME SHAPE,
|
|
264
|
+
* deliberately. One question — is this a mapping block? — with one answer, rather than
|
|
265
|
+
* two outcomes hanging on whether a second rule happened to fall inside the ceiling.
|
|
266
|
+
*/
|
|
267
|
+
if (!looksLikeMapping(lines, 1, index)) {
|
|
268
|
+
return prepended(keyLine, content);
|
|
269
|
+
}
|
|
270
|
+
// ⚠ THE KEY LINE MATCHES THE DOCUMENT'S OWN ENDING, taken from the closing fence it is
|
|
271
|
+
// inserted above. A lone LF in a CRLF document is a mixed-ending file.
|
|
272
|
+
const spliced = [...lines.slice(0, index), keyLine + endingOf(line), ...lines.slice(index)];
|
|
273
|
+
return spliced.join('\n');
|
|
274
|
+
}
|
|
275
|
+
if (keyOf(line) === FRONTMATTER_KEY) {
|
|
276
|
+
// ⚠ THE CONFLICT REFUSES; IT DOES NOT OVERWRITE. The existing value may be a previous
|
|
277
|
+
// stamp or may be the user's own key — this module cannot tell, and replacing either
|
|
278
|
+
// one destroys information the caller never offered up. Refusing is the only outcome
|
|
279
|
+
// that is right in both readings.
|
|
280
|
+
return scribeRefuse('FRONTMATTER_CONFLICT', `the page already carries a top-level ${FRONTMATTER_KEY} key`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* No closing fence within the ceiling.
|
|
285
|
+
*
|
|
286
|
+
* ⚠⚠ AND THE MAPPING TEST DECIDES WHICH FAILURE THIS IS, which is the second half of the
|
|
287
|
+
* horizontal-rule fix. If what follows the first line reads as a mapping, this really is an
|
|
288
|
+
* unclosed frontmatter block and it REFUSES — prepending a second block would hand back a
|
|
289
|
+
* document with two, which is why the ceiling refuses rather than falling through.
|
|
290
|
+
*
|
|
291
|
+
* If it does NOT read as a mapping, the first line was a horizontal rule and this document has
|
|
292
|
+
* no frontmatter at all. It gets a block prepended, exactly as a document opening with prose
|
|
293
|
+
* would. The old code refused here, which made an ordinary Markdown file — a rule, then prose —
|
|
294
|
+
* permanently unwritable through this server, with a message about a closing fence the user had
|
|
295
|
+
* no reason to think they owed.
|
|
296
|
+
*/
|
|
297
|
+
if (!looksLikeMapping(lines, 1, ceiling)) {
|
|
298
|
+
return prepended(keyLine, content);
|
|
299
|
+
}
|
|
300
|
+
return scribeRefuse('FRONTMATTER_INVALID', `the page opens a frontmatter block with no closing --- within ${MAX_FRONTMATTER_LINES} lines`);
|
|
301
|
+
}
|
|
302
|
+
//# sourceMappingURL=frontmatter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../src/frontmatter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,MAAM,CAAC,MAAM,eAAe,GAAG,cAAc,CAAC;AAE9C;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAmBzC;;;;;;GAMG;AACH,MAAM,UAAU,OAAO,CAAC,MAAqB;IACzC,OAAO;QACH,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,OAAO,EAAE,MAAM,CAAC,OAAO;KAC1B,CAAC;AACN,CAAC;AAED;;;;;;GAMG;AACH,SAAS,OAAO,CAAC,IAAY;IACzB,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC;AACtE,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAS,KAAK,CAAC,IAAY;IACvB,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5D,gGAAgG;IAChG,8FAA8F;IAC9F,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAChC,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5B,OAAO,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAS,OAAO,CAAC,IAAY;IACzB,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACjC,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACtB,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,KAAK,KAAK,GAAG,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3D,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,SAAS,gBAAgB,CAAC,KAAwB,EAAE,IAAY,EAAE,EAAU;IACxE,KAAK,IAAI,KAAK,GAAG,IAAI,EAAE,KAAK,GAAG,EAAE,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC5C,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAW,CAAC;QACnC,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QACzD,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACvC,4FAA4F;QAC5F,oEAAoE;QACpE,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,SAAS;QAC/B,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACnC,2FAA2F;QAC3F,4FAA4F;QAC5F,2DAA2D;QAC3D,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI;YAAE,SAAS;QACnC,OAAO,KAAK,CAAC;IACjB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,QAAQ,CAAC,IAAY;IAC1B,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,SAAS,SAAS,CAAC,OAAe,EAAE,OAAe;IAC/C,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,MAAM,GAAG,GAAG,UAAU,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/E,OAAO,MAAM,GAAG,GAAG,OAAO,GAAG,GAAG,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;AAC1D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,KAAK,CAAC,OAAe,EAAE,UAAiC;IACpE,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;IACzC,MAAM,OAAO,GAAG,GAAG,eAAe,KAAK,KAAK,EAAE,CAAC;IAE/C,8FAA8F;IAC9F,gGAAgG;IAChG,6FAA6F;IAC7F,8BAA8B;IAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAClC,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,CAAC,CAAW,CAAC,CAAC;IAE9D,IAAI,CAAC,KAAK,EAAE,CAAC;QACT,OAAO,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;IAC9D,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,OAAO,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAW,CAAC;QACpC,IAAI,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAChB;;;;;;;;;;;;;;;;eAgBG;YACH,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;gBACrC,OAAO,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YACvC,CAAC;YACD,uFAAuF;YACvF,uEAAuE;YACvE,MAAM,OAAO,GAAG,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;YAC5F,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,eAAe,EAAE,CAAC;YAClC,sFAAsF;YACtF,qFAAqF;YACrF,qFAAqF;YACrF,kCAAkC;YAClC,OAAO,YAAY,CACf,sBAAsB,EACtB,wCAAwC,eAAe,MAAM,CAChE,CAAC;QACN,CAAC;IACL,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,CAAC;QACvC,OAAO,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC;IAED,OAAO,YAAY,CACf,qBAAqB,EACrB,iEAAiE,qBAAqB,QAAQ,CACjG,CAAC;AACN,CAAC"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
3
|
+
import { createFsGate } from 'wyrd-fence';
|
|
4
|
+
import { main } from './main.js';
|
|
5
|
+
await main({
|
|
6
|
+
argv: process.argv.slice(2),
|
|
7
|
+
env: process.env,
|
|
8
|
+
makeFsGate: createFsGate,
|
|
9
|
+
makeTransport: () => new StdioServerTransport(),
|
|
10
|
+
stderr: line => process.stderr.write(`${line}\n`),
|
|
11
|
+
setExitCode: code => {
|
|
12
|
+
process.exitCode = code;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,MAAM,IAAI,CAAC;IACP,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3B,GAAG,EAAE,OAAO,CAAC,GAAG;IAChB,UAAU,EAAE,YAAY;IACxB,aAAa,EAAE,GAAG,EAAE,CAAC,IAAI,oBAAoB,EAAE;IAC/C,MAAM,EAAE,IAAI,CAAC,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC;IACjD,WAAW,EAAE,IAAI,CAAC,EAAE;QAChB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAC5B,CAAC;CACJ,CAAC,CAAC"}
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LEDGER SEAM — a TYPE, the production appender over the fence, and a refusing one for the arms.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ THE APPEND IS THE FENCE'S, NEVER THIS FILE'S. B4 rules the append is one `O_APPEND` write of
|
|
5
|
+
* one fully-serialised line, and `FsGate.appendLineInGrant` is that write — landed 2026-09-02 with
|
|
6
|
+
* its own containment argument, its own name screens and its own identity re-proof. Everything
|
|
7
|
+
* `gateAppender` below does is name the path and hand the bytes over. Reaching around the gate with
|
|
8
|
+
* `fs.appendFileSync` would put a second, unfenced write path in the very package whose D8 argument
|
|
9
|
+
* is that there is exactly one — and it is worth saying plainly that the temptation is real, since
|
|
10
|
+
* an append to a known relative path looks like the most harmless call in the file.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ THIS FILE USED TO SAY THE PRIMITIVE DID NOT EXIST, AND THE ARM IT NAMED AS OWED IS NOW PAID.
|
|
13
|
+
* `ST22`–`ST25` append real lines to a real `.wyrd/lineage.jsonl` over a real grant: one line
|
|
14
|
+
* landing, three accumulating in order, a parent deleted underneath the write refusing after the
|
|
15
|
+
* page was created, and eight concurrent writers producing eight intact lines. The `ST-OWED`
|
|
16
|
+
* inventory row is gone rather than annotated, because an inventory that describes a debt already
|
|
17
|
+
* settled is worse than no row at all.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ INJECTED, NOT IMPORTED, at the composition root. `writePage` takes the appender as a parameter
|
|
20
|
+
* so the arms can supply a spy or a refuser without the production path acquiring a mode flag. A
|
|
21
|
+
* module that knows whether it is under test is a module whose test proves less than it looks like.
|
|
22
|
+
*/
|
|
23
|
+
import type { FenceRefusal, FsGate } from 'wyrd-fence';
|
|
24
|
+
/** What an append reports on success. `bytes` is the line INCLUDING its terminating LF. */
|
|
25
|
+
export interface Appended {
|
|
26
|
+
readonly ok: true;
|
|
27
|
+
readonly bytes: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* ⚠ THE PARAMETER IS A FULLY-SERIALISED BUFFER, AND THE TYPE IS THE RULE. B4's atomicity argument
|
|
31
|
+
* holds for ONE write of ONE complete line; an implementation handed a record and left to serialise
|
|
32
|
+
* it could emit in pieces. Handing over bytes makes "never split a line" a property of the
|
|
33
|
+
* signature rather than of a comment inside whoever implements it.
|
|
34
|
+
*/
|
|
35
|
+
export interface LedgerAppender {
|
|
36
|
+
appendLine(line: Buffer): Promise<Appended | FenceRefusal>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* THE PRODUCTION APPENDER. One line, through the gate, to the vault's own lineage log.
|
|
40
|
+
*
|
|
41
|
+
* ⚠⚠ THE FENCE'S RESULT IS PASSED THROUGH UNCHANGED, NOT REPACKAGED, and that is a decision rather
|
|
42
|
+
* than laziness. The fence's `Appended` carries `rel` alongside `ok` and `bytes`, so it already
|
|
43
|
+
* satisfies this module's narrower `Appended`; its `WriteRefusal` is a `FenceRefusal` carrying
|
|
44
|
+
* `retained`, which is the field that tells a caller whether a failed append may have left a
|
|
45
|
+
* partial line behind. Narrowing either one to this file's shape would DISCARD exactly the
|
|
46
|
+
* diagnostics the fence spent its slice producing — and `stamp.ts` hands the refusal straight to
|
|
47
|
+
* the caller inside `PAGE_WRITTEN_LEDGER_FAILED`, where the retention answer is the whole point.
|
|
48
|
+
*
|
|
49
|
+
* ⚠ THE PATH IS A CONSTANT AND THE CALLER CANNOT CHOOSE IT. A lineage log the caller can redirect
|
|
50
|
+
* is a provenance record that can be written somewhere nobody reads — so the only path this
|
|
51
|
+
* appender will ever name is `config.ts`'s `LINEAGE_PATH`, and the constant is shared with the
|
|
52
|
+
* config loader so the ledger and the config can never disagree about which directory is the
|
|
53
|
+
* vault's.
|
|
54
|
+
*
|
|
55
|
+
* ⚠ NO RETRY, ANYWHERE ABOVE THE FENCE EITHER. The fence refuses to retry under `O_APPEND` because
|
|
56
|
+
* a second attempt re-selects the end of file and splices this record through whatever another
|
|
57
|
+
* appender landed in between; a retry loop added HERE would manufacture precisely that, one level
|
|
58
|
+
* up, where the fence's comment explaining why cannot be seen.
|
|
59
|
+
*/
|
|
60
|
+
export declare function gateAppender(gate: FsGate): LedgerAppender;
|
|
61
|
+
/**
|
|
62
|
+
* The appender the arms use to exercise the failure branch.
|
|
63
|
+
*
|
|
64
|
+
* ⚠ IT REFUSES RATHER THAN NO-OPS, deliberately. A no-op appender would make every arm above it
|
|
65
|
+
* green while the ledger silently recorded nothing — the exact "green on a path that does nothing"
|
|
66
|
+
* shape the suite runner exists to make impossible. It is no longer what the package ships with:
|
|
67
|
+
* `createScribe` now defaults to `gateAppender`, and the argument for that change is in `index.ts`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function refusingAppender(detail?: string): LedgerAppender;
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LEDGER SEAM — a TYPE, the production appender over the fence, and a refusing one for the arms.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ THE APPEND IS THE FENCE'S, NEVER THIS FILE'S. B4 rules the append is one `O_APPEND` write of
|
|
5
|
+
* one fully-serialised line, and `FsGate.appendLineInGrant` is that write — landed 2026-09-02 with
|
|
6
|
+
* its own containment argument, its own name screens and its own identity re-proof. Everything
|
|
7
|
+
* `gateAppender` below does is name the path and hand the bytes over. Reaching around the gate with
|
|
8
|
+
* `fs.appendFileSync` would put a second, unfenced write path in the very package whose D8 argument
|
|
9
|
+
* is that there is exactly one — and it is worth saying plainly that the temptation is real, since
|
|
10
|
+
* an append to a known relative path looks like the most harmless call in the file.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ THIS FILE USED TO SAY THE PRIMITIVE DID NOT EXIST, AND THE ARM IT NAMED AS OWED IS NOW PAID.
|
|
13
|
+
* `ST22`–`ST25` append real lines to a real `.wyrd/lineage.jsonl` over a real grant: one line
|
|
14
|
+
* landing, three accumulating in order, a parent deleted underneath the write refusing after the
|
|
15
|
+
* page was created, and eight concurrent writers producing eight intact lines. The `ST-OWED`
|
|
16
|
+
* inventory row is gone rather than annotated, because an inventory that describes a debt already
|
|
17
|
+
* settled is worse than no row at all.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ INJECTED, NOT IMPORTED, at the composition root. `writePage` takes the appender as a parameter
|
|
20
|
+
* so the arms can supply a spy or a refuser without the production path acquiring a mode flag. A
|
|
21
|
+
* module that knows whether it is under test is a module whose test proves less than it looks like.
|
|
22
|
+
*/
|
|
23
|
+
import { LINEAGE_PATH } from './config.js';
|
|
24
|
+
/**
|
|
25
|
+
* THE PRODUCTION APPENDER. One line, through the gate, to the vault's own lineage log.
|
|
26
|
+
*
|
|
27
|
+
* ⚠⚠ THE FENCE'S RESULT IS PASSED THROUGH UNCHANGED, NOT REPACKAGED, and that is a decision rather
|
|
28
|
+
* than laziness. The fence's `Appended` carries `rel` alongside `ok` and `bytes`, so it already
|
|
29
|
+
* satisfies this module's narrower `Appended`; its `WriteRefusal` is a `FenceRefusal` carrying
|
|
30
|
+
* `retained`, which is the field that tells a caller whether a failed append may have left a
|
|
31
|
+
* partial line behind. Narrowing either one to this file's shape would DISCARD exactly the
|
|
32
|
+
* diagnostics the fence spent its slice producing — and `stamp.ts` hands the refusal straight to
|
|
33
|
+
* the caller inside `PAGE_WRITTEN_LEDGER_FAILED`, where the retention answer is the whole point.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ THE PATH IS A CONSTANT AND THE CALLER CANNOT CHOOSE IT. A lineage log the caller can redirect
|
|
36
|
+
* is a provenance record that can be written somewhere nobody reads — so the only path this
|
|
37
|
+
* appender will ever name is `config.ts`'s `LINEAGE_PATH`, and the constant is shared with the
|
|
38
|
+
* config loader so the ledger and the config can never disagree about which directory is the
|
|
39
|
+
* vault's.
|
|
40
|
+
*
|
|
41
|
+
* ⚠ NO RETRY, ANYWHERE ABOVE THE FENCE EITHER. The fence refuses to retry under `O_APPEND` because
|
|
42
|
+
* a second attempt re-selects the end of file and splices this record through whatever another
|
|
43
|
+
* appender landed in between; a retry loop added HERE would manufacture precisely that, one level
|
|
44
|
+
* up, where the fence's comment explaining why cannot be seen.
|
|
45
|
+
*/
|
|
46
|
+
export function gateAppender(gate) {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
appendLine: (line) => gate.appendLineInGrant(LINEAGE_PATH, line)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The appender the arms use to exercise the failure branch.
|
|
53
|
+
*
|
|
54
|
+
* ⚠ IT REFUSES RATHER THAN NO-OPS, deliberately. A no-op appender would make every arm above it
|
|
55
|
+
* green while the ledger silently recorded nothing — the exact "green on a path that does nothing"
|
|
56
|
+
* shape the suite runner exists to make impossible. It is no longer what the package ships with:
|
|
57
|
+
* `createScribe` now defaults to `gateAppender`, and the argument for that change is in `index.ts`.
|
|
58
|
+
*/
|
|
59
|
+
export function refusingAppender(detail = 'the ledger append was refused') {
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
appendLine: async () => Object.freeze({ ok: false, reason: 'IO_ERROR', detail, resolvedPath: '' })
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ledger.js","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAkB3C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACrC,OAAO,MAAM,CAAC,MAAM,CAAC;QACjB,UAAU,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,iBAAiB,CAAC,YAAY,EAAE,IAAI,CAAC;KAC3E,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAM,GAAG,+BAA+B;IACrE,OAAO,MAAM,CAAC,MAAM,CAAC;QACjB,UAAU,EAAE,KAAK,IAA2B,EAAE,CAC1C,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,KAAc,EAAE,MAAM,EAAE,UAAmB,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE,EAAE,CAAC;KACnG,CAAC,CAAC;AACP,CAAC"}
|