unknown-knowledge 2.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.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,802 @@
1
+ /**
2
+ * Format-adapter registry (UCS-1153) — every submission becomes ONE IR.
3
+ *
4
+ * The pipeline ingests documents in several shapes (md, txt, html, pdf) and
5
+ * must reason over all of them identically. An adapter is the seam that makes
6
+ * that possible: a small deterministic recipe `adapt(text|bytes) -> Block[]`
7
+ * that normalizes a source document into ORDERED BLOCKS WITH KINDS AND SOURCE
8
+ * LOCATORS, and nothing else. Everything downstream (UCS-1156's coverage map)
9
+ * reads the IR, never the original bytes.
10
+ *
11
+ * The contract is the extractor-kind contract, deliberately (lib/extractor-
12
+ * kinds.js teaches the original; the shipped adapter pairs carry the shape):
13
+ *
14
+ * 1. PURE + DETERMINISTIC — source in, blocks out. Same input bytes produce
15
+ * the same IR bytes, every run: no wall clock, no filesystem, no
16
+ * enumeration order, no randomness. Pinned by repeated-run tests.
17
+ * 2. LEXICAL ONLY — no client-code execution, no subprocess, no network,
18
+ * no eval (D-014). An adapter reads bytes and returns data. `zlib` is
19
+ * the one decode primitive used (pdf FlateDecode); it is stdlib
20
+ * decompression, not execution.
21
+ * 3. HARD-ERROR, NEVER A PARTIAL — a format with no adapter, or content
22
+ * outside an adapter's declared envelope, throws. A silent partial parse
23
+ * poisons what the team believes was reviewed: the coverage map would
24
+ * report a document as covered when half of it was never read. That is
25
+ * the D-005/D-012 false-all-clear failure class, and it is worse than no
26
+ * check at all.
27
+ * 4. VERSIONED — each adapter carries a VERSION surfaced in the IR
28
+ * (`adapter: "md@1"`), so a map's provenance names the exact recipe that
29
+ * produced it (D-005-style trust). Bump rules below.
30
+ *
31
+ * SCOPE: deterministic text only. Scanned pages and images are converted
32
+ * UPSTREAM by the agent (a probabilistic act) and resubmitted as a text
33
+ * artifact — this pipeline never guesses at pixels.
34
+ *
35
+ * VERSION BUMP RULES. The version is a promise about IR bytes:
36
+ * - BUMP when the same input would now produce different IR — a new block
37
+ * kind, a changed locator scheme, different splitting or ordering. A
38
+ * coverage map built under md@1 and one built under md@2 are not
39
+ * comparable, and the version is what says so.
40
+ * - DO NOT bump for a widened envelope that leaves previously-accepted
41
+ * input byte-identical (accepting a shape that used to hard-error is
42
+ * additive: nothing that parsed before changes).
43
+ * - Bumping requires updating the adapter's fixture pair in the same
44
+ * commit; tests pin sample -> EXPECTED.yaml, so the pair can never rot
45
+ * apart from the recipe.
46
+ */
47
+ import { inflateSync } from 'node:zlib';
48
+ import { EngineRefusal } from './engine-refusal.js';
49
+
50
+ /**
51
+ * Both adapter failures are ANTICIPATED REFUSALS, not bugs, so both extend
52
+ * `EngineRefusal` (lib/engine-refusal.js): the surface reports them as a clean
53
+ * message with conduct and exits 2, rather than letting the harness print a
54
+ * stack trace. An unsupported format is a condition the engine reached on
55
+ * purpose — the refusal IS the feature — and a stack trace would bury the
56
+ * conduct the submitter needs to read.
57
+ */
58
+ /** The submission's format has no adapter, or its content is out of envelope. */
59
+ export class UnsupportedFormatError extends EngineRefusal {
60
+ name = 'UnsupportedFormatError';
61
+ }
62
+ /** The adapter could not read a document out of the source at all. */
63
+ export class AdaptError extends EngineRefusal {
64
+ name = 'AdaptError';
65
+ }
66
+
67
+ /**
68
+ * The conduct an out-of-envelope submission must state (PRD §5.1). A refusal
69
+ * that only says "no" leaves the submitter stuck, so the error names every
70
+ * legitimate way forward — convert, author an adapter, or (for scanned input)
71
+ * convert upstream and resubmit the text. This text is a golden: tests pin it,
72
+ * because the conduct IS the deliverable of the hard error.
73
+ */
74
+ export const CONDUCT = [
75
+ 'conduct:',
76
+ ' 1. convert to md / txt / html / pdf and resubmit',
77
+ ' 2. or author an adapter — versioned code + fixture (sample -> expected IR),',
78
+ ' same pattern as extractor kinds; the miss feeds the adapter backlog',
79
+ ' 3. scanned/image content: convert upstream (probabilistic), then resubmit',
80
+ ' the text artifact — this pipeline only ingests deterministic text',
81
+ ].join('\n');
82
+
83
+ /**
84
+ * The IR block kinds — the whole taxonomy, closed on purpose.
85
+ *
86
+ * These are the distinctions a coverage map needs to reason about richness:
87
+ * a heading anchors a section, a paragraph carries prose, a list-item is a
88
+ * discrete assertion, code is content no lexicon should tokenize as prose,
89
+ * and a table-row is tabular data. A format that cannot distinguish some of
90
+ * these (plain text has no headings) simply emits fewer kinds — it never
91
+ * invents structure it cannot see. Adding a kind is an IR change: every
92
+ * adapter's version bumps, because downstream consumers keyed on the old set.
93
+ */
94
+ export const BLOCK_KINDS = Object.freeze(['heading', 'paragraph', 'list-item', 'code', 'table-row']);
95
+
96
+ /**
97
+ * Normalize line endings without changing the line COUNT: locators are
98
+ * line-based and must index the source the reader will open. A lone \r
99
+ * (classic Mac) is a line break the reader's editor shows as one, so it
100
+ * counts; \r\n is one break, not two.
101
+ */
102
+ const splitLines = (text) => text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
103
+
104
+ /**
105
+ * A block, canonical shape. Keys are emitted in this fixed order so the IR
106
+ * serializes byte-identically regardless of construction order.
107
+ *
108
+ * @param {string} kind one of BLOCK_KINDS
109
+ * @param {string} text the block's text content, already normalized
110
+ * @param {object} locator where in the source it came from
111
+ */
112
+ function block(kind, text, locator) {
113
+ return { kind, text, locator };
114
+ }
115
+
116
+ /**
117
+ * Collapse a block's inline whitespace to single spaces and trim.
118
+ *
119
+ * The IR is for lexical scanning, not re-rendering: a paragraph wrapped across
120
+ * four source lines is ONE assertion, and a downstream phrase match must not
121
+ * miss it because a newline fell mid-phrase. The locator preserves where the
122
+ * text lives, so nothing about the source is lost — only its line wrapping,
123
+ * which was never content.
124
+ */
125
+ const flatten = (text) => text.replace(/\s+/g, ' ').trim();
126
+
127
+ // ------------------------------------------------------------------ markdown
128
+
129
+ /** Closing fences, by opening marker. Fixed patterns, compiled once. */
130
+ const CLOSING_BACKTICK_FENCE = /^\s*`{3,}\s*$/;
131
+ const CLOSING_TILDE_FENCE = /^\s*~{3,}\s*$/;
132
+
133
+ /**
134
+ * `md@1` — CommonMark-subset structure: ATX headings, fenced code, list items,
135
+ * table rows, paragraphs. Locator: `{ line, endLine }`, 1-based inclusive,
136
+ * indexing the ORIGINAL source lines.
137
+ *
138
+ * Envelope (what this adapter claims to read):
139
+ * - ATX headings (`#`..`######`) — the `level` rides on the block.
140
+ * - Fenced code blocks (``` or ~~~). Content is emitted verbatim, NOT
141
+ * flattened: whitespace is semantic in code, and a lexicon must be able
142
+ * to tell code from prose. An unterminated fence is a hard error — the
143
+ * rest of the document would silently become code, which is exactly the
144
+ * kind of confident wrong parse that poisons a coverage map.
145
+ * - List items (`-`, `*`, `+`, or `1.`), one block each, nesting flattened:
146
+ * the IR records the assertion, not the outline depth.
147
+ * - Table rows (`| a | b |`), one block each; the delimiter row (`|---|`)
148
+ * is presentation and is dropped.
149
+ * - Everything else is a paragraph, broken on blank lines.
150
+ * Setext headings (underlined with === or ---) are deliberately NOT read as
151
+ * headings: `---` is also a thematic break and a frontmatter fence, and
152
+ * guessing between them is the confident-wrong-parse failure class. They
153
+ * parse as paragraphs — visible content, honestly labeled, never invented
154
+ * structure.
155
+ */
156
+ function adaptMarkdown(text) {
157
+ const lines = splitLines(text);
158
+ const blocks = [];
159
+ let paragraph = null;
160
+
161
+ const flushParagraph = () => {
162
+ if (paragraph === null) return;
163
+ const body = flatten(paragraph.text);
164
+ // A "paragraph" of only whitespace is not content; dropping it invents
165
+ // nothing and hides nothing.
166
+ if (body !== '') blocks.push(block('paragraph', body, { line: paragraph.line, endLine: paragraph.endLine }));
167
+ paragraph = null;
168
+ };
169
+
170
+ for (let i = 0; i < lines.length; i += 1) {
171
+ const line = lines[i];
172
+ const at = i + 1;
173
+
174
+ const fence = /^\s*(```+|~~~+)(.*)$/.exec(line);
175
+ if (fence) {
176
+ flushParagraph();
177
+ // The two closing patterns are fixed, so they are built once at module
178
+ // load rather than recompiled for every line of every fenced block.
179
+ const closing = fence[1][0] === '`' ? CLOSING_BACKTICK_FENCE : CLOSING_TILDE_FENCE;
180
+ const body = [];
181
+ let j = i + 1;
182
+ let closed = false;
183
+ for (; j < lines.length; j += 1) {
184
+ if (closing.test(lines[j])) { closed = true; break; }
185
+ body.push(lines[j]);
186
+ }
187
+ if (!closed) {
188
+ throw new AdaptError(`md: unterminated code fence opened at line ${at} — the rest of the document would silently become code, and a partial IR poisons what the reader believes was reviewed (PRD §5.1)`);
189
+ }
190
+ blocks.push(block('code', body.join('\n'), { line: at, endLine: j + 1 }));
191
+ i = j;
192
+ continue;
193
+ }
194
+
195
+ const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
196
+ if (heading) {
197
+ flushParagraph();
198
+ const b = block('heading', flatten(heading[2]), { line: at, endLine: at });
199
+ b.level = heading[1].length;
200
+ blocks.push(b);
201
+ continue;
202
+ }
203
+
204
+ if (/^\s*$/.test(line)) { flushParagraph(); continue; }
205
+
206
+ const item = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
207
+ if (item) {
208
+ flushParagraph();
209
+ blocks.push(block('list-item', flatten(item[1]), { line: at, endLine: at }));
210
+ continue;
211
+ }
212
+
213
+ if (/^\s*\|.*\|\s*$/.test(line)) {
214
+ flushParagraph();
215
+ // The delimiter row carries no content — it is table syntax.
216
+ if (!/^\s*\|[\s:|-]+\|\s*$/.test(line)) {
217
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(flatten);
218
+ blocks.push(block('table-row', cells.join(' | '), { line: at, endLine: at }));
219
+ }
220
+ continue;
221
+ }
222
+
223
+ if (paragraph === null) paragraph = { text: line, line: at, endLine: at };
224
+ else { paragraph.text += ` ${line}`; paragraph.endLine = at; }
225
+ }
226
+ flushParagraph();
227
+ return blocks;
228
+ }
229
+
230
+ // ---------------------------------------------------------------- plain text
231
+
232
+ /**
233
+ * `txt@1` — blank-line-separated paragraphs, and nothing else.
234
+ *
235
+ * Plain text has no marked-up structure, so this adapter INVENTS NONE: every
236
+ * block is a paragraph. Reading indented lines as code, or short lines as
237
+ * headings, would be a guess dressed as structure, and a coverage map built on
238
+ * guessed headings reports sections that do not exist. Degraded structure,
239
+ * identical process — the map is bounded by content richness, and txt is the
240
+ * honest floor. Locator: `{ line, endLine }`, 1-based inclusive.
241
+ */
242
+ function adaptText(text) {
243
+ const lines = splitLines(text);
244
+ const blocks = [];
245
+ let current = null;
246
+ const flush = () => {
247
+ if (current === null) return;
248
+ const body = flatten(current.text);
249
+ if (body !== '') blocks.push(block('paragraph', body, { line: current.line, endLine: current.endLine }));
250
+ current = null;
251
+ };
252
+ for (let i = 0; i < lines.length; i += 1) {
253
+ if (/^\s*$/.test(lines[i])) { flush(); continue; }
254
+ if (current === null) current = { text: lines[i], line: i + 1, endLine: i + 1 };
255
+ else { current.text += ` ${lines[i]}`; current.endLine = i + 1; }
256
+ }
257
+ flush();
258
+ return blocks;
259
+ }
260
+
261
+ // ----------------------------------------------------------------------- html
262
+
263
+ /** Elements whose CONTENT is not document text — never emitted, never scanned. */
264
+ const HTML_SKIP = new Set(['script', 'style', 'noscript', 'template', 'svg', 'head']);
265
+
266
+ /** Tag -> block kind. Everything else is structural or inline. */
267
+ const HTML_BLOCK_TAGS = new Map([
268
+ ['h1', 'heading'], ['h2', 'heading'], ['h3', 'heading'],
269
+ ['h4', 'heading'], ['h5', 'heading'], ['h6', 'heading'],
270
+ ['p', 'paragraph'], ['li', 'list-item'],
271
+ ['pre', 'code'], ['tr', 'table-row'],
272
+ ['blockquote', 'paragraph'],
273
+ ]);
274
+
275
+ /**
276
+ * The five XML/HTML named entities plus the numeric forms — the whole set this
277
+ * adapter decodes. A document using any other named entity (`&nbsp;`,
278
+ * `&mdash;`) is OUT OF ENVELOPE rather than silently passed through as literal
279
+ * `&nbsp;` text: the block's text would not be the text a reader sees, and a
280
+ * lexicon matching against it would silently miss. The full HTML5 entity table
281
+ * is ~2200 names; shipping a partial one that quietly mis-decodes is worse
282
+ * than refusing, and this is a zero-dependency engine (D-022).
283
+ */
284
+ function decodeEntities(text, context) {
285
+ return text.replace(/&(#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
286
+ if (body[0] === '#') {
287
+ const code = body[1] === 'x' || body[1] === 'X'
288
+ ? Number.parseInt(body.slice(2), 16)
289
+ : Number.parseInt(body.slice(1), 10);
290
+ if (!Number.isInteger(code) || code < 0 || code > 0x10FFFF) {
291
+ throw new UnsupportedFormatError(`html: ${context}: numeric entity "${whole}" is not a valid code point — out of the html adapter's envelope`);
292
+ }
293
+ return String.fromCodePoint(code);
294
+ }
295
+ switch (body) {
296
+ case 'amp': return '&';
297
+ case 'lt': return '<';
298
+ case 'gt': return '>';
299
+ case 'quot': return '"';
300
+ case 'apos': return "'";
301
+ default:
302
+ throw new UnsupportedFormatError(`html: ${context}: named entity "${whole}" is outside this adapter's envelope (&amp; &lt; &gt; &quot; &apos; and numeric forms only) — passing it through as literal text would make the block's text differ from what a reader sees, so a lexicon would silently miss it; convert the document or author a widened adapter (PRD §5.1)`);
303
+ }
304
+ });
305
+ }
306
+
307
+ /**
308
+ * `html@1` — lexical tag walk, no DOM, no execution. Locator: `{ line,
309
+ * endLine }`, 1-based inclusive, from the byte offset of the element's open
310
+ * and close tags.
311
+ *
312
+ * Envelope: the block elements in HTML_BLOCK_TAGS, whose text content becomes
313
+ * one block each. Content of script/style/head is dropped entirely — it is
314
+ * code and metadata, never document prose, and feeding it to a lexicon would
315
+ * manufacture matches from CSS selectors.
316
+ *
317
+ * NESTING: CHILD KIND WINS. A block element opening inside another closes its
318
+ * parent, and the text belongs to the innermost element — so `<li><p>x</p></li>`
319
+ * emits ONE block of kind `paragraph`, not `list-item`. The block IS a
320
+ * paragraph; its list membership is outline structure, and the IR flattens
321
+ * outline structure by design (the md adapter flattens list nesting the same
322
+ * way). Emitting the parent's kind would mean choosing an ancestor's label for
323
+ * text it does not directly contain, and with arbitrary nesting there is no
324
+ * principled stopping point up that chain. A list item whose text is direct
325
+ * (`<li>x</li>`) still emits `list-item` — the common case is unaffected.
326
+ *
327
+ * D-014, restated for the format most likely to carry it: this adapter NEVER
328
+ * executes anything. `<script>` bodies are discarded as text; no DOM is built;
329
+ * no resource is fetched; a `src`/`href` is inert. The document is bytes.
330
+ *
331
+ * Out of envelope: an unclosed block element (the block's extent would be a
332
+ * guess), and any named entity outside the five XML ones (see decodeEntities).
333
+ */
334
+ function adaptHtml(text) {
335
+ const source = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
336
+
337
+ // Newline offsets ONCE, then binary search per lookup. Rescanning from the
338
+ // start for every locator is O(document x blocks) — quadratic on exactly the
339
+ // large documents this seam exists to handle.
340
+ const newlines = [];
341
+ for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1)) newlines.push(i);
342
+ /** 1-based line containing `offset`: 1 + how many newlines precede it. */
343
+ const lineAt = (offset) => {
344
+ let low = 0;
345
+ let high = newlines.length;
346
+ while (low < high) {
347
+ const mid = (low + high) >> 1;
348
+ if (newlines[mid] < offset) low = mid + 1;
349
+ else high = mid;
350
+ }
351
+ return low + 1;
352
+ };
353
+
354
+ const blocks = [];
355
+ const open = []; // stack of { tag, kind, textStart, tagStart }
356
+ let i = 0;
357
+ let skipDepth = 0;
358
+ let skipTag = null;
359
+
360
+ const emit = (frame, closeAt) => {
361
+ const raw = source.slice(frame.textStart, closeAt);
362
+ // Cell boundaries are content in a table row: without them "Field Meaning"
363
+ // reads as one phrase. The separator matches the markdown adapter's, so a
364
+ // table row means the same thing in the IR whatever format it arrived in.
365
+ const celled = frame.kind === 'table-row'
366
+ ? raw.replace(/<\/t[dh]>\s*<t[dh][^>]*>/gi, ' | ')
367
+ : raw;
368
+ // Strip any nested inline markup: the IR carries text, not markup. The
369
+ // space keeps `a<br>b` from fusing into "ab"; in CODE it would instead
370
+ // introduce indentation the source never had, so a code block strips the
371
+ // tag outright and its whitespace stays byte-exact.
372
+ const stripped = frame.kind === 'code'
373
+ ? celled.replace(/<[^>]*>/g, '')
374
+ : celled.replace(/<[^>]*>/g, ' ');
375
+ const openLine = lineAt(frame.tagStart);
376
+ const decoded = decodeEntities(stripped, `<${frame.tag}> at line ${openLine}`);
377
+ // Code keeps its interior whitespace — it is content — so only the
378
+ // surrounding blank lines the markup introduced come off.
379
+ const body = frame.kind === 'code' ? decoded.replace(/^\n+|\s+$/g, '') : flatten(decoded);
380
+ if (body === '') return;
381
+ const b = block(frame.kind, body, { line: openLine, endLine: lineAt(closeAt) });
382
+ if (frame.kind === 'heading' && /^h[1-6]$/.test(frame.tag)) b.level = Number(frame.tag[1]);
383
+ blocks.push(b);
384
+ };
385
+
386
+ while (i < source.length) {
387
+ const lt = source.indexOf('<', i);
388
+ if (lt === -1) break;
389
+
390
+ if (source.startsWith('<!--', lt)) {
391
+ const end = source.indexOf('-->', lt + 4);
392
+ if (end === -1) break; // an unterminated comment ends the document
393
+ i = end + 3;
394
+ continue;
395
+ }
396
+ if (source.startsWith('<!', lt)) { // doctype
397
+ const end = source.indexOf('>', lt);
398
+ if (end === -1) break;
399
+ i = end + 1;
400
+ continue;
401
+ }
402
+
403
+ const gt = source.indexOf('>', lt);
404
+ if (gt === -1) break;
405
+ const inner = source.slice(lt + 1, gt);
406
+ const closing = inner.startsWith('/');
407
+ const name = /^\/?\s*([a-zA-Z][\w-]*)/.exec(inner)?.[1]?.toLowerCase();
408
+ if (!name) { i = gt + 1; continue; }
409
+ const selfClosing = inner.endsWith('/');
410
+
411
+ if (skipDepth > 0) {
412
+ if (name === skipTag) skipDepth += closing ? -1 : 1;
413
+ if (skipDepth === 0) skipTag = null;
414
+ i = gt + 1;
415
+ continue;
416
+ }
417
+ if (!closing && HTML_SKIP.has(name) && !selfClosing) {
418
+ skipDepth = 1;
419
+ skipTag = name;
420
+ i = gt + 1;
421
+ continue;
422
+ }
423
+
424
+ const kind = HTML_BLOCK_TAGS.get(name);
425
+ if (kind && !selfClosing) {
426
+ if (closing) {
427
+ // Close the matching frame; anything opened inside it and never closed
428
+ // is a malformed nesting the walk cannot honestly resolve.
429
+ const idx = open.findLastIndex((f) => f.tag === name);
430
+ if (idx === -1) { i = gt + 1; continue; } // stray close: ignorable
431
+ emit(open[idx], lt);
432
+ open.length = idx;
433
+ } else {
434
+ // A block element opening inside another closes it: `<li><p>x</p></li>`
435
+ // must not emit the list item twice.
436
+ if (open.length) {
437
+ const parent = open[open.length - 1];
438
+ emit(parent, lt);
439
+ open.pop();
440
+ }
441
+ open.push({ tag: name, kind, textStart: gt + 1, tagStart: lt });
442
+ }
443
+ }
444
+ i = gt + 1;
445
+ }
446
+
447
+ if (open.length) {
448
+ const frame = open[open.length - 1];
449
+ throw new AdaptError(`html: <${frame.tag}> opened at line ${lineAt(frame.tagStart)} is never closed — the block's extent would be a guess, and a partial IR poisons what the reader believes was reviewed (PRD §5.1)`);
450
+ }
451
+ return blocks;
452
+ }
453
+
454
+ // ------------------------------------------------------------------------ pdf
455
+
456
+ /**
457
+ * `pdf@1` — a minimal, honest, LEXICAL PDF text extractor.
458
+ *
459
+ * There is no PDF dependency here and there never will be one: js-yaml is the
460
+ * engine's only runtime dependency (D-022), and shelling out to `pdftotext`
461
+ * would be a subprocess — exactly what D-014 forbids. So this adapter parses
462
+ * the file format itself, and its envelope is deliberately NARROW: it supports
463
+ * only what the shipped fixture demonstrates, and everything else is a HARD
464
+ * ERROR that names what it hit.
465
+ *
466
+ * IN ENVELOPE:
467
+ * - Uncompressed content streams, and FlateDecode streams (inflated with
468
+ * node:zlib — stdlib DECOMPRESSION, not execution: no code from the
469
+ * document is ever run, and zlib cannot run any).
470
+ * - Text-showing operators `Tj`, `TJ`, `'`, `"` inside BT/ET blocks, in
471
+ * content-stream order.
472
+ * - Literal `(...)` strings with the standard backslash escapes, and hex
473
+ * `<...>` strings, in the document's default (Latin) encoding.
474
+ *
475
+ * OUT OF ENVELOPE — each a hard error naming what was encountered, because the
476
+ * out-of-envelope discipline applies WITHIN an adapter, not just between
477
+ * formats. A PDF that is 90% extractable is the most dangerous input in this
478
+ * whole pipeline: the coverage map would look complete.
479
+ * - ENCRYPTED documents (`/Encrypt`): the bytes are not the text.
480
+ * - Any stream filter other than FlateDecode (LZW, DCT, JBIG2, ASCII85, …).
481
+ * - A document with NO extractable text (the scanned-page case): scanned
482
+ * content is converted UPSTREAM by the agent and resubmitted as text.
483
+ * - Custom font encodings via /Differences: the glyph codes would not be
484
+ * the characters, so extracted "text" would be plausible mojibake.
485
+ *
486
+ * LOCATOR: `{ page, object }` — the 1-based page ordinal in the document's
487
+ * page order, and the 1-based ordinal of the text-showing operation within
488
+ * that page's content stream. A byte offset would be useless to a reader (a
489
+ * PDF is not line-addressable and the bytes may be compressed), while
490
+ * page + object is what a human opening the file can actually find, and it is
491
+ * fully deterministic: it derives from content-stream order, never from
492
+ * enumeration order or object numbering.
493
+ */
494
+ function adaptPdf(bytes) {
495
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
496
+ // `latin1` is a byte-preserving round trip: every byte maps to one code unit,
497
+ // so the structure scan below is exact on binary content.
498
+ const raw = buffer.toString('latin1');
499
+
500
+ if (!raw.startsWith('%PDF-')) {
501
+ throw new UnsupportedFormatError('pdf: the file does not begin with the %PDF- header — it is not a PDF, and guessing at its structure would be a confident wrong parse (PRD §5.1)');
502
+ }
503
+ if (/\/Encrypt\b/.test(raw)) {
504
+ throw new UnsupportedFormatError(`pdf: the document is ENCRYPTED (/Encrypt) — its content streams are ciphertext, so any "text" extracted from them would be noise presented as content. Out of the pdf@${ADAPTERS.pdf.version} envelope.\n${CONDUCT}`);
505
+ }
506
+ if (/\/Differences\b/.test(raw)) {
507
+ throw new UnsupportedFormatError(`pdf: the document declares a custom font encoding (/Differences) — glyph codes do not map to their characters, so extracted text would be plausible mojibake: exactly the confident wrong parse a coverage map must never carry. Out of the pdf@${ADAPTERS.pdf.version} envelope.\n${CONDUCT}`);
508
+ }
509
+
510
+ // ---- objects: `N G obj ... endobj`, scanned lexically in file order.
511
+ const objects = new Map();
512
+ const objectRe = /(\d+)\s+(\d+)\s+obj\b/g;
513
+ let match;
514
+ while ((match = objectRe.exec(raw)) !== null) {
515
+ const id = Number(match[1]);
516
+ const bodyStart = match.index + match[0].length;
517
+ const end = raw.indexOf('endobj', bodyStart);
518
+ if (end === -1) {
519
+ throw new AdaptError(`pdf: object ${id} is never terminated by "endobj" — the file is truncated or malformed; a partial parse would silently drop content (PRD §5.1)`);
520
+ }
521
+ // Last definition wins: an incrementally-updated PDF appends newer
522
+ // revisions of an object, and the later one is the live one.
523
+ objects.set(id, { id, body: raw.slice(bodyStart, end), start: bodyStart });
524
+ }
525
+ if (objects.size === 0) {
526
+ throw new AdaptError('pdf: no PDF objects found — the file is not a readable PDF document');
527
+ }
528
+
529
+ /** Inflate or pass through one stream body, refusing every other filter. */
530
+ const streamOf = (object) => {
531
+ const streamAt = object.body.indexOf('stream');
532
+ if (streamAt === -1) return null;
533
+ const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(object.body.slice(0, streamAt));
534
+ let start = streamAt + 'stream'.length;
535
+ if (raw[object.start + start] === '\r') start += 1;
536
+ if (raw[object.start + start] === '\n') start += 1;
537
+ const endAt = object.body.indexOf('endstream', start);
538
+ if (endAt === -1) {
539
+ throw new AdaptError(`pdf: object ${object.id} opens a stream that is never closed by "endstream" — malformed; a partial parse would silently drop content`);
540
+ }
541
+ const slice = buffer.subarray(object.start + start, object.start + endAt);
542
+ if (!filter) return slice.toString('latin1');
543
+ const name = filter[1].replace(/[[\]\s]/g, '');
544
+ if (name !== '/FlateDecode' && name !== '/Fl') {
545
+ throw new UnsupportedFormatError(`pdf: object ${object.id} uses the stream filter ${name} — the pdf@${ADAPTERS.pdf.version} adapter decodes uncompressed and /FlateDecode streams only. Decoding it wrongly would yield noise presented as document text.\n${CONDUCT}`);
546
+ }
547
+ try {
548
+ return inflateSync(slice).toString('latin1');
549
+ } catch (error) {
550
+ throw new AdaptError(`pdf: object ${object.id}: FlateDecode stream could not be inflated (${error.message}) — the file is corrupt or the filter is misdeclared; a partial parse would silently drop content`);
551
+ }
552
+ };
553
+
554
+ // ---- pages, in document order. /Type /Page objects are scanned in file
555
+ // order, which for a hand-authored/linearized PDF IS page order; the ordinal
556
+ // is the page's position in that scan, so it is stable for identical bytes.
557
+ const pages = [];
558
+ for (const object of objects.values()) {
559
+ if (!/\/Type\s*\/Page\b/.test(object.body)) continue;
560
+ const contents = /\/Contents\s+(\d+)\s+\d+\s+R/.exec(object.body);
561
+ if (!contents) {
562
+ // A page with inline or array contents is a shape this envelope does not
563
+ // read; refusing names it rather than emitting a page with no text.
564
+ throw new UnsupportedFormatError(`pdf: a /Page object references its /Contents in a form the pdf@${ADAPTERS.pdf.version} adapter does not read (a single indirect reference is the declared envelope) — refusing rather than emitting a page whose text was never read.\n${CONDUCT}`);
565
+ }
566
+ pages.push(Number(contents[1]));
567
+ }
568
+ if (pages.length === 0) {
569
+ throw new AdaptError('pdf: no /Type /Page objects found — the document has no pages this adapter can read');
570
+ }
571
+
572
+ const blocks = [];
573
+ for (let p = 0; p < pages.length; p += 1) {
574
+ const content = objects.get(pages[p]);
575
+ if (!content) {
576
+ throw new AdaptError(`pdf: page ${p + 1} references content object ${pages[p]}, which does not exist — the file is malformed`);
577
+ }
578
+ const stream = streamOf(content);
579
+ if (stream === null) {
580
+ throw new AdaptError(`pdf: page ${p + 1}'s content object ${pages[p]} carries no stream — nothing to read`);
581
+ }
582
+ for (const text of showTextOperations(stream, p + 1)) {
583
+ blocks.push(block('paragraph', text.value, { page: p + 1, object: text.ordinal }));
584
+ }
585
+ }
586
+
587
+ if (blocks.length === 0) {
588
+ throw new UnsupportedFormatError(`pdf: the document carries NO extractable text — every page is likely scanned images. This pipeline ingests deterministic text only; image conversion is upstream and probabilistic, so it is out of the pdf@${ADAPTERS.pdf.version} envelope rather than a best-effort partial.\n${CONDUCT}`);
589
+ }
590
+ return blocks;
591
+ }
592
+
593
+ /**
594
+ * Walk one content stream and yield its text-showing operations in order.
595
+ *
596
+ * A content stream is postfix: operands precede the operator. This walk tracks
597
+ * only what it needs — the most recent string operands — and recognizes the
598
+ * four text-showing operators. Everything else (positioning, graphics state)
599
+ * is skipped: it affects layout, and the IR carries text, not layout.
600
+ *
601
+ * Consecutive shows are joined into one block per `Tj`/`TJ`/`'`/`"` operation,
602
+ * which is the finest granularity the format offers honestly: PDF has no
603
+ * paragraph concept, so inventing one by clustering coordinates would be a
604
+ * guess. One operation, one block, one locator ordinal.
605
+ */
606
+ function showTextOperations(stream, page) {
607
+ const out = [];
608
+ const operands = [];
609
+ let i = 0;
610
+ let ordinal = 0;
611
+
612
+ while (i < stream.length) {
613
+ const ch = stream[i];
614
+ if (ch === '%') { // comment to end of line
615
+ const nl = stream.indexOf('\n', i);
616
+ i = nl === -1 ? stream.length : nl + 1;
617
+ continue;
618
+ }
619
+ if (/\s/.test(ch)) { i += 1; continue; }
620
+
621
+ if (ch === '(') {
622
+ const { value, end } = readLiteralString(stream, i, page);
623
+ operands.push(value);
624
+ i = end;
625
+ continue;
626
+ }
627
+ if (ch === '<' && stream[i + 1] !== '<') {
628
+ const close = stream.indexOf('>', i);
629
+ if (close === -1) throw new AdaptError(`pdf: page ${page}: unterminated hex string`);
630
+ const hex = stream.slice(i + 1, close).replace(/\s/g, '');
631
+ if (!/^[0-9a-fA-F]*$/.test(hex)) {
632
+ throw new AdaptError(`pdf: page ${page}: malformed hex string — a partial decode would present noise as text`);
633
+ }
634
+ const padded = hex.length % 2 ? `${hex}0` : hex;
635
+ operands.push(Buffer.from(padded, 'hex').toString('latin1'));
636
+ i = close + 1;
637
+ continue;
638
+ }
639
+ if (ch === '<' || ch === '[' || ch === ']' || ch === '{' || ch === '}') {
640
+ // `<<` dictionaries and array delimiters: `[` and `]` bracket a TJ array,
641
+ // whose string operands we have already collected in order.
642
+ if (ch === '<' && stream[i + 1] === '<') { i += 2; continue; }
643
+ i += 1;
644
+ continue;
645
+ }
646
+
647
+ // A token: an operator, a name (/Foo), or a number.
648
+ const token = /^[^\s()<>[\]{}/%]+|^\/[^\s()<>[\]{}/%]*/.exec(stream.slice(i));
649
+ if (!token) { i += 1; continue; }
650
+ const word = token[0];
651
+ i += word.length;
652
+
653
+ if (word === 'Tj' || word === 'TJ' || word === "'" || word === '"') {
654
+ const value = flatten(decodePdfText(operands.join('')));
655
+ ordinal += 1;
656
+ if (value !== '') out.push({ value, ordinal });
657
+ operands.length = 0;
658
+ continue;
659
+ }
660
+ // Any other operator consumes its operands.
661
+ if (!word.startsWith('/') && !/^[-+.\d]/.test(word)) operands.length = 0;
662
+ }
663
+ return out;
664
+ }
665
+
666
+ /** PDF literal string: balanced parens, backslash escapes, `\ddd` octal. */
667
+ function readLiteralString(stream, start, page) {
668
+ let depth = 0;
669
+ let out = '';
670
+ let i = start;
671
+ while (i < stream.length) {
672
+ const ch = stream[i];
673
+ if (ch === '\\') {
674
+ const next = stream[i + 1];
675
+ const simple = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '(': '(', ')': ')', '\\': '\\' };
676
+ if (next in simple) { out += simple[next]; i += 2; continue; }
677
+ const octal = /^[0-7]{1,3}/.exec(stream.slice(i + 1));
678
+ if (octal) {
679
+ out += String.fromCharCode(Number.parseInt(octal[0], 8));
680
+ i += 1 + octal[0].length;
681
+ continue;
682
+ }
683
+ if (next === '\n') { i += 2; continue; } // line continuation
684
+ i += 1; // stray backslash: the spec says drop it
685
+ continue;
686
+ }
687
+ if (ch === '(') { depth += 1; if (depth > 1) out += ch; i += 1; continue; }
688
+ if (ch === ')') {
689
+ depth -= 1;
690
+ if (depth === 0) return { value: out, end: i + 1 };
691
+ out += ch;
692
+ i += 1;
693
+ continue;
694
+ }
695
+ out += ch;
696
+ i += 1;
697
+ }
698
+ throw new AdaptError(`pdf: page ${page}: unterminated literal string — the file is malformed; a partial parse would silently drop content`);
699
+ }
700
+
701
+ /**
702
+ * PDF text bytes are latin1 code units under the default encodings this
703
+ * envelope accepts. A byte above 0x7F that is not valid PDFDocEncoding-as-
704
+ * latin1 text is left as-is: it round-trips its own byte. UTF-16BE strings
705
+ * (BOM-prefixed, per the spec) are decoded — that spelling is unambiguous.
706
+ */
707
+ function decodePdfText(value) {
708
+ if (value.charCodeAt(0) === 0xFE && value.charCodeAt(1) === 0xFF) {
709
+ return Buffer.from(value.slice(2), 'latin1').swap16().toString('utf16le');
710
+ }
711
+ return value;
712
+ }
713
+
714
+ // -------------------------------------------------------------- the registry
715
+
716
+ /**
717
+ * Format name -> adapter. Each entry is frozen and carries:
718
+ * - `version` the recipe's version, surfaced in the IR as `<name>@<n>`
719
+ * - `extensions` the file extensions that dispatch to it
720
+ * - `binary` whether `adapt` receives a Buffer (pdf) or a string
721
+ * - `adapt` the pure recipe: source -> Block[]
722
+ *
723
+ * Clients author later adapters exactly as they author extractor kinds: a
724
+ * versioned module here plus a shipped sample/expected pair beside the others,
725
+ * pinned by a test (D-005 — only vendored, versioned, test-covered code runs).
726
+ */
727
+ export const ADAPTERS = Object.freeze({
728
+ md: Object.freeze({ version: 1, extensions: ['.md', '.markdown'], binary: false, adapt: adaptMarkdown }),
729
+ txt: Object.freeze({ version: 1, extensions: ['.txt', '.text'], binary: false, adapt: adaptText }),
730
+ html: Object.freeze({ version: 1, extensions: ['.html', '.htm'], binary: false, adapt: adaptHtml }),
731
+ pdf: Object.freeze({ version: 1, extensions: ['.pdf'], binary: true, adapt: adaptPdf }),
732
+ });
733
+
734
+ /** The formats an out-of-envelope message offers, in a stable order. */
735
+ export const SUPPORTED = Object.freeze(Object.keys(ADAPTERS).sort());
736
+
737
+ /**
738
+ * Dispatch BY FILE EXTENSION, never by content sniffing.
739
+ *
740
+ * The grammars are disjoint, and guessing one from lookalike bytes is the
741
+ * confident-wrong-parse failure class (the same call `strings-keys` makes in
742
+ * lib/extractor-kinds.js). A `.docx` renamed to `.md` should fail loudly on
743
+ * its content, not be silently misread — and an extension nobody adapted is
744
+ * the hard error this ticket exists to guarantee.
745
+ *
746
+ * @param {string} path the submission's path (only its extension is read)
747
+ * @returns {{ name: string, version: number, binary: boolean, adapt: Function }}
748
+ */
749
+ export function adapterFor(path) {
750
+ const dot = path.lastIndexOf('.');
751
+ const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
752
+ const extension = dot > slash ? path.slice(dot).toLowerCase() : '';
753
+ for (const [name, adapter] of Object.entries(ADAPTERS)) {
754
+ if (adapter.extensions.includes(extension)) return { name, ...adapter };
755
+ }
756
+ const named = extension === '' ? 'a file with no extension' : `'${extension}'`;
757
+ throw new UnsupportedFormatError(`no format adapter for ${named} — out-of-envelope input.\nA best-effort partial parse is never offered: it would poison what the team believes was reviewed (PRD §5.1). Adapters: ${SUPPORTED.join(', ')}.\n${CONDUCT}`);
758
+ }
759
+
760
+ /**
761
+ * Adapt one submission into the IR envelope.
762
+ *
763
+ * The returned object is the whole contract UCS-1156 builds on: the adapter's
764
+ * provenance, a content hash of the SOURCE BYTES (so identical submissions
765
+ * dedupe), and the ordered blocks. Deterministic in full — no timestamps, no
766
+ * paths beyond the caller's own, no enumeration order.
767
+ *
768
+ * @param {string} path the submission's path, for dispatch and provenance
769
+ * @param {Buffer} bytes the submission's bytes
770
+ * @returns {{ adapter: string, hash: string, blocks: object[] }}
771
+ */
772
+ export function adapt(path, bytes) {
773
+ const adapter = adapterFor(path);
774
+ const source = adapter.binary ? bytes : bytes.toString('utf8');
775
+ const blocks = adapter.adapt(source);
776
+ if (blocks.length === 0) {
777
+ throw new AdaptError(`${adapter.name}: the document produced no blocks — it is empty, or its content is in a shape this adapter cannot see. An empty IR would report a reviewed document as covered, so it is a hard error (PRD §5.1).\n${CONDUCT}`);
778
+ }
779
+ return {
780
+ adapter: `${adapter.name}@${adapter.version}`,
781
+ hash: `fnv1a64:${fnv1a64(bytes)}`,
782
+ blocks,
783
+ };
784
+ }
785
+
786
+ /**
787
+ * FNV-1a (64-bit) over the source bytes, as 16 lowercase hex digits.
788
+ *
789
+ * A content hash, not a cryptographic one: its job is to say "these two
790
+ * submissions are the same bytes" so resubmissions dedupe. Hand-rolled over
791
+ * BigInt because the engine has no crypto budget to spend and `node:crypto`
792
+ * would be a heavier dependency on a hot path than this arithmetic.
793
+ */
794
+ function fnv1a64(bytes) {
795
+ const prime = 0x100000001b3n;
796
+ const mask = 0xFFFFFFFFFFFFFFFFn;
797
+ let hash = 0xcbf29ce484222325n;
798
+ for (const byte of bytes) {
799
+ hash = ((hash ^ BigInt(byte)) * prime) & mask;
800
+ }
801
+ return hash.toString(16).padStart(16, '0');
802
+ }