sfora-cli 0.9.0 → 0.11.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 (108) hide show
  1. package/README.md +147 -6
  2. package/dist/SforaFs.js +278 -10
  3. package/dist/api-client.d.ts +290 -5
  4. package/dist/api-client.js +307 -22
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +323 -29
  8. package/dist/format/__tests__/byteStable.d.ts +5 -0
  9. package/dist/format/__tests__/byteStable.js +64 -0
  10. package/dist/format/blockSplice.d.ts +135 -0
  11. package/dist/format/blockSplice.js +330 -0
  12. package/dist/format/blocks/dropClosure.d.ts +81 -0
  13. package/dist/format/blocks/dropClosure.js +196 -0
  14. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  15. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  16. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  17. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  18. package/dist/format/blocks/parsers.d.ts +105 -0
  19. package/dist/format/blocks/parsers.js +442 -0
  20. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  21. package/dist/format/blocks/structured-block-schema.js +30 -0
  22. package/dist/format/callout.d.ts +128 -0
  23. package/dist/format/callout.js +227 -0
  24. package/dist/format/cardMarkdown.d.ts +2 -0
  25. package/dist/format/cardMarkdown.js +10 -0
  26. package/dist/format/checklist.d.ts +34 -0
  27. package/dist/format/checklist.js +158 -0
  28. package/dist/format/formatAxes.d.ts +228 -0
  29. package/dist/format/formatAxes.js +454 -0
  30. package/dist/format/index.d.ts +19 -4
  31. package/dist/format/index.js +28 -4
  32. package/dist/format/lineGeometry.d.ts +100 -0
  33. package/dist/format/lineGeometry.js +424 -0
  34. package/dist/format/lint/appliesTo.d.ts +92 -0
  35. package/dist/format/lint/appliesTo.js +369 -0
  36. package/dist/format/lint/config.d.ts +106 -0
  37. package/dist/format/lint/config.js +205 -0
  38. package/dist/format/lint/fixAll.d.ts +62 -0
  39. package/dist/format/lint/fixAll.js +107 -0
  40. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  41. package/dist/format/lint/frontmatterSchema.js +660 -0
  42. package/dist/format/lint/index.d.ts +49 -0
  43. package/dist/format/lint/index.js +51 -0
  44. package/dist/format/lint/lintSource.d.ts +56 -0
  45. package/dist/format/lint/lintSource.js +188 -0
  46. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  47. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  48. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  49. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  50. package/dist/format/lint/rules/index.d.ts +11 -0
  51. package/dist/format/lint/rules/index.js +32 -0
  52. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  53. package/dist/format/lint/rules/malformed-callout.js +88 -0
  54. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  55. package/dist/format/lint/rules/malformed-checklist.js +65 -0
  56. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  57. package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
  58. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  59. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  60. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  61. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  62. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  63. package/dist/format/lint/rules/orphan-reference.js +87 -0
  64. package/dist/format/lint/severity.d.ts +15 -0
  65. package/dist/format/lint/severity.js +50 -0
  66. package/dist/format/lint/textEdits.d.ts +86 -0
  67. package/dist/format/lint/textEdits.js +162 -0
  68. package/dist/format/lint/types.d.ts +116 -0
  69. package/dist/format/lint/types.js +16 -0
  70. package/dist/format/markdown/dates.js +2 -0
  71. package/dist/format/markdown/document.js +2 -0
  72. package/dist/format/markdown/index.js +2 -0
  73. package/dist/format/markdown/mentions.js +2 -0
  74. package/dist/format/markdown/slug.d.ts +28 -0
  75. package/dist/format/markdown/slug.js +65 -0
  76. package/dist/format/markdown/yaml.js +2 -0
  77. package/dist/format/noteMarkdown.js +2 -0
  78. package/dist/format/parseWithFallback.d.ts +13 -0
  79. package/dist/format/parseWithFallback.js +98 -0
  80. package/dist/format/plaintext.d.ts +5 -0
  81. package/dist/format/plaintext.js +51 -0
  82. package/dist/format/postMarkdown.js +3 -1
  83. package/dist/format/sheetCellSpans.d.ts +95 -0
  84. package/dist/format/sheetCellSpans.js +223 -0
  85. package/dist/format/sheetSelection.d.ts +136 -0
  86. package/dist/format/sheetSelection.js +282 -0
  87. package/dist/format/taskUploadFilename.d.ts +6 -0
  88. package/dist/format/taskUploadFilename.js +13 -0
  89. package/dist/format/textStats.d.ts +23 -0
  90. package/dist/format/textStats.js +80 -0
  91. package/dist/format/wayfinder.d.ts +50 -0
  92. package/dist/format/wayfinder.js +203 -0
  93. package/dist/format/wikiLinks.d.ts +78 -0
  94. package/dist/format/wikiLinks.js +266 -0
  95. package/dist/index.d.ts +26 -1
  96. package/dist/index.js +20 -3
  97. package/dist/mcp-server.js +5 -2
  98. package/dist/opener.d.ts +23 -0
  99. package/dist/opener.js +26 -0
  100. package/dist/render.d.ts +132 -0
  101. package/dist/render.js +208 -0
  102. package/dist/shell-commands.d.ts +34 -0
  103. package/dist/shell-commands.js +108 -0
  104. package/dist/watch.d.ts +79 -0
  105. package/dist/watch.js +113 -0
  106. package/dist/web-url.d.ts +39 -0
  107. package/dist/web-url.js +63 -0
  108. package/package.json +7 -6
@@ -0,0 +1,64 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // Round-trip assertions for the document/entity serializers.
4
+ //
5
+ // Two properties, checked separately, because they are not the same claim:
6
+ //
7
+ // byte-stable — one round trip reaches a fixpoint: roundTrip(roundTrip(s))
8
+ // is byte-identical to roundTrip(s). A serializer is allowed to
9
+ // canonicalize on first contact; it is not allowed to keep
10
+ // changing its mind. This is the property that stops documents
11
+ // growing a heading or a fence per save.
12
+ // identity — roundTrip(s) === s. Only true for sources that are already
13
+ // canonical, i.e. ones our own serializers emitted.
14
+ //
15
+ // No vitest import on purpose: this file lives under src/, so packages/sfora's
16
+ // sync-format copies it into the published CLI tarball and it has to compile
17
+ // with nothing but TypeScript.
18
+ import { buildDocument } from "../markdown/document.js";
19
+ import { serializeFrontmatter } from "../markdown/yaml.js";
20
+ import { parseDocumentWithFallback } from "../parseWithFallback.js";
21
+ // The document envelope round trip: read a file the way /v1/fs reads it, write
22
+ // it back the way /v1/fs writes it. Frontmatter keys keep their source order
23
+ // (object insertion order), so a clean file comes back unchanged.
24
+ export const documentRoundTrip = (source) => {
25
+ const parsed = parseDocumentWithFallback(source);
26
+ const fm = serializeFrontmatter(Object.entries(parsed.frontmatter));
27
+ return buildDocument(fm, parsed.title, parsed.body);
28
+ };
29
+ function describeDivergence(a, b) {
30
+ const limit = Math.min(a.length, b.length);
31
+ let i = 0;
32
+ while (i < limit && a[i] === b[i])
33
+ i++;
34
+ const window = (s) => JSON.stringify(s.slice(Math.max(0, i - 40), i + 40));
35
+ return `first divergence at char ${i}\n pass 1: ${window(a)}\n pass 2: ${window(b)}`;
36
+ }
37
+ // parse → serialize → parse → serialize; the second pass must be byte-identical
38
+ // to the first.
39
+ export function assertByteStable(source, roundTrip = documentRoundTrip) {
40
+ const once = roundTrip(source);
41
+ const twice = roundTrip(once);
42
+ if (twice !== once) {
43
+ throw new Error(`round trip is not byte-stable: ${describeDivergence(once, twice)}`);
44
+ }
45
+ }
46
+ // Stronger: the source is already canonical and must survive untouched.
47
+ export function assertRoundTripIdentity(source, roundTrip = documentRoundTrip) {
48
+ const once = roundTrip(source);
49
+ if (once !== source) {
50
+ throw new Error(`round trip changed a canonical source: ${describeDivergence(source, once)}`);
51
+ }
52
+ assertByteStable(source, roundTrip);
53
+ }
54
+ // True when one round trip is a fixpoint — the predicate form, for corpora where
55
+ // some divergence is expected and counted against a baseline rather than failing.
56
+ export function isByteStable(source, roundTrip = documentRoundTrip) {
57
+ try {
58
+ assertByteStable(source, roundTrip);
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * One block of a source string: its byte range, and its identity.
3
+ *
4
+ * `key` is what "unchanged" means. Two blocks with the same key are the same
5
+ * block for splice purposes and the old one's bytes win. A caller that keys by
6
+ * raw text gets a byte-exact diff (and no canonicalization softening at all);
7
+ * a caller that keys by a position-stripped parse gets the semantic diff this
8
+ * module exists to serve.
9
+ */
10
+ export interface SourceBlock {
11
+ /** Byte offset of the block's first byte. */
12
+ start: number;
13
+ /** One past the block's last byte. */
14
+ end: number;
15
+ /** Identity under whatever equality the caller decided on. */
16
+ key: string;
17
+ }
18
+ /** Why a splice gave up and handed back the new source whole. */
19
+ export type BlockSpliceFallback = "none"
20
+ /** One side has no blocks at all — there is nothing to preserve or to keep. */
21
+ | "no-blocks"
22
+ /** A block list was out of order, overlapping, or out of bounds. */
23
+ | "unusable-blocks";
24
+ export interface BlockSpliceResult {
25
+ /** The bytes to store. */
26
+ source: string;
27
+ /** Old blocks that kept their bytes. */
28
+ kept: number;
29
+ /** Blocks written from the new source. */
30
+ rewritten: number;
31
+ /** Set when the result is just `newSource`. */
32
+ fallback: BlockSpliceFallback;
33
+ }
34
+ /**
35
+ * Above this many DP cells the middle region is treated as one changed run
36
+ * instead of being aligned properly.
37
+ *
38
+ * The prefix/suffix trim below already handles the shape a real edit has — one
39
+ * changed region, everything before and after it identical — in linear time,
40
+ * so the quadratic pass only ever runs on a genuinely scattered diff. 250k
41
+ * cells is a 500-block-by-500-block middle, which is a document nobody has;
42
+ * past it the splice degrades to exactly what open-knowledge's
43
+ * `map-driven-splice.ts` does at every size (one over-wide contiguous splice),
44
+ * which is correct, merely less preserving.
45
+ *
46
+ * Exported so the test that pins the boundary can build the block lists either
47
+ * side of it from this number rather than from a second copy of it: a budget
48
+ * quietly raised past a hard-coded 500×500 would leave a test that still
49
+ * passes while asserting nothing about the fallback.
50
+ */
51
+ export declare const ALIGNMENT_CELL_BUDGET = 250000;
52
+ /**
53
+ * A run of blocks that is either kept from old or written from new.
54
+ *
55
+ * `kept: true` means every block in the range keyed identically on both sides,
56
+ * one for one, so `oldTo - oldFrom === newTo - newFrom` and the i-th old block
57
+ * IS the i-th new block. `kept: false` means the two ranges are the region the
58
+ * alignment could not explain: they may be different lengths, and nothing here
59
+ * says which old block became which new one — that judgement belongs to the
60
+ * consumer, because the splice does not need it and a rebinding ledger does.
61
+ */
62
+ export interface BlockRun {
63
+ kept: boolean;
64
+ /** Half-open index range into the old block list. */
65
+ oldFrom: number;
66
+ oldTo: number;
67
+ /** Half-open index range into the new block list. */
68
+ newFrom: number;
69
+ newTo: number;
70
+ }
71
+ /**
72
+ * How much of the correspondence was actually computed.
73
+ *
74
+ * `"exact"` is a real alignment: every block that survives unchanged is inside
75
+ * a kept run. `"budget"` says the middle region was past
76
+ * `ALIGNMENT_CELL_BUDGET` and was handed back as one changed run without being
77
+ * looked at, so blocks that did survive are sitting inside it unrecognised.
78
+ * The splice does not care — an over-wide changed run is merely less
79
+ * preserving — but a consumer that reads a changed run as "these blocks were
80
+ * edited" would be stating something the alignment never checked, so the
81
+ * degradation is reported rather than inferred from the run shapes.
82
+ */
83
+ export type BlockAlignmentQuality = "exact" | "budget";
84
+ /** The old↔new block correspondence, and how far it was actually worked out. */
85
+ export interface BlockAlignment {
86
+ /**
87
+ * Alternating kept/changed runs covering both lists end to end: every index
88
+ * of `oldBlocks` appears in exactly one run's old range, every index of
89
+ * `newBlocks` in exactly one run's new range, both in ascending order.
90
+ */
91
+ runs: BlockRun[];
92
+ quality: BlockAlignmentQuality;
93
+ }
94
+ /**
95
+ * Assemble the bytes to store from `oldSource` and `newSource`.
96
+ *
97
+ * The contract, in order of how much it matters:
98
+ *
99
+ * 1. If every key matches, the result is `oldSource` byte-for-byte. Not
100
+ * "equivalent to" — identical, including its trailing newline or lack of
101
+ * one. A save that changed nothing must reach the store as no change at
102
+ * all, or the whole exercise is decorative.
103
+ * 2. A block whose key changed contributes its NEW bytes, together with the
104
+ * gap bytes on either side of it, because a new or deleted block has to be
105
+ * able to bring its own blank lines.
106
+ * 3. A block whose key did not change contributes its OLD bytes, and so do
107
+ * the gaps between two such blocks.
108
+ * 4. The bytes outside every block — leading whitespace, the trailing
109
+ * newline — belong to no block, so they follow the block nearest them: old
110
+ * if that first/last block was kept, new if it was rewritten. Kept-at-the-
111
+ * edge is what makes rule 1 exact; rewritten-at-the-edge is what lets a
112
+ * document that really was replaced arrive with its own final newline
113
+ * instead of inheriting the absence of one.
114
+ */
115
+ export declare function spliceBlocks(oldSource: string, newSource: string, oldBlocks: readonly SourceBlock[], newBlocks: readonly SourceBlock[]): BlockSpliceResult;
116
+ /**
117
+ * Split both block lists into alternating kept/changed runs — the old↔new
118
+ * correspondence, on its own, with no bytes involved.
119
+ *
120
+ * Common prefix and suffix first — that is the entire diff for a normal edit
121
+ * and it costs one pass. What is left in the middle gets a longest-common-
122
+ * subsequence alignment so that two edits with untouched blocks between them
123
+ * keep those blocks' bytes; open-knowledge's version collapses that case into
124
+ * one over-wide splice, and it is the one thing here worth doing better than
125
+ * the reference, because "find and replace in three places" is an ordinary
126
+ * afternoon and it should not rewrite the paragraphs in between.
127
+ *
128
+ * Public because it is most of a rebinding ledger and it already runs on every
129
+ * write. `spliceBlocks` reads it for bytes; a caller that has derived an
130
+ * identity from the same keys reads it for what became what. Only `key` is
131
+ * consulted, so a caller holding keys and no offsets can ask this directly —
132
+ * the offset sanity `spliceBlocks` insists on is a slicing concern, not an
133
+ * alignment one.
134
+ */
135
+ export declare function alignBlockRuns(oldBlocks: readonly SourceBlock[], newBlocks: readonly SourceBlock[]): BlockAlignment;
@@ -0,0 +1,330 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // The block-aligned splice: write only the blocks that changed.
4
+ //
5
+ // Every save in sfora goes through a serializer, and a serializer has
6
+ // opinions — `*em*` becomes `_em_`, a setext heading becomes an ATX one, a
7
+ // table's padding is re-laid. Those opinions are correct for the block the
8
+ // author just edited and pure vandalism everywhere else: change one word in
9
+ // paragraph nine and the naive write path hands the store a document in which
10
+ // all forty blocks have been re-spelled. The author's diff is unreadable, the
11
+ // activity trail lies about what changed, and every byte-form the serializer
12
+ // cannot represent is gone for good.
13
+ //
14
+ // This module is the fix, and it is deliberately the dumbest possible one.
15
+ // It knows nothing about serializers, editors or markdown syntax. It takes
16
+ // two strings and two lists of BLOCKS — byte ranges plus an identity key —
17
+ // and returns a third string assembled from old bytes wherever the keys agree
18
+ // and new bytes wherever they do not. The key is where the intelligence
19
+ // lives, and it lives in the caller: `blockSpliceMdast.ts` derives one by
20
+ // parsing and stripping positions, so `*em*` and `_em_` key the same and the
21
+ // OLD bytes survive. Nothing in this file can tell the difference, which is
22
+ // exactly why it will survive the serializer swap wave 2 is heading for.
23
+ //
24
+ // There are two exits. `spliceBlocks` is the one the write paths call and
25
+ // returns bytes. `alignBlockRuns` is the correspondence underneath it —
26
+ // which old block became which new one — returned on its own, because that
27
+ // question has a second consumer: a document's derived block ids change when
28
+ // their blocks change, and the ledger that carries an anchor across an edit
29
+ // needs exactly this answer and nothing else from this file. It was already
30
+ // being computed on every save and thrown away.
31
+ //
32
+ // Zero dependencies, no parser, no I/O — it ships in the CLI.
33
+ /**
34
+ * Above this many DP cells the middle region is treated as one changed run
35
+ * instead of being aligned properly.
36
+ *
37
+ * The prefix/suffix trim below already handles the shape a real edit has — one
38
+ * changed region, everything before and after it identical — in linear time,
39
+ * so the quadratic pass only ever runs on a genuinely scattered diff. 250k
40
+ * cells is a 500-block-by-500-block middle, which is a document nobody has;
41
+ * past it the splice degrades to exactly what open-knowledge's
42
+ * `map-driven-splice.ts` does at every size (one over-wide contiguous splice),
43
+ * which is correct, merely less preserving.
44
+ *
45
+ * Exported so the test that pins the boundary can build the block lists either
46
+ * side of it from this number rather than from a second copy of it: a budget
47
+ * quietly raised past a hard-coded 500×500 would leave a test that still
48
+ * passes while asserting nothing about the fallback.
49
+ */
50
+ export const ALIGNMENT_CELL_BUDGET = 250_000;
51
+ /**
52
+ * Assemble the bytes to store from `oldSource` and `newSource`.
53
+ *
54
+ * The contract, in order of how much it matters:
55
+ *
56
+ * 1. If every key matches, the result is `oldSource` byte-for-byte. Not
57
+ * "equivalent to" — identical, including its trailing newline or lack of
58
+ * one. A save that changed nothing must reach the store as no change at
59
+ * all, or the whole exercise is decorative.
60
+ * 2. A block whose key changed contributes its NEW bytes, together with the
61
+ * gap bytes on either side of it, because a new or deleted block has to be
62
+ * able to bring its own blank lines.
63
+ * 3. A block whose key did not change contributes its OLD bytes, and so do
64
+ * the gaps between two such blocks.
65
+ * 4. The bytes outside every block — leading whitespace, the trailing
66
+ * newline — belong to no block, so they follow the block nearest them: old
67
+ * if that first/last block was kept, new if it was rewritten. Kept-at-the-
68
+ * edge is what makes rule 1 exact; rewritten-at-the-edge is what lets a
69
+ * document that really was replaced arrive with its own final newline
70
+ * instead of inheriting the absence of one.
71
+ */
72
+ export function spliceBlocks(oldSource, newSource, oldBlocks, newBlocks) {
73
+ if (oldBlocks.length === 0 || newBlocks.length === 0) {
74
+ return { source: newSource, kept: 0, rewritten: 0, fallback: "no-blocks" };
75
+ }
76
+ if (!blocksAreUsable(oldBlocks, oldSource.length) ||
77
+ !blocksAreUsable(newBlocks, newSource.length)) {
78
+ return {
79
+ source: newSource,
80
+ kept: 0,
81
+ rewritten: 0,
82
+ fallback: "unusable-blocks",
83
+ };
84
+ }
85
+ const { runs } = alignBlockRuns(oldBlocks, newBlocks);
86
+ const parts = [];
87
+ // The head: everything before the first block, from whichever side owns it.
88
+ parts.push(runs[0].kept
89
+ ? oldSource.slice(0, oldBlocks[0].start)
90
+ : newSource.slice(0, newBlocks[0].start));
91
+ let kept = 0;
92
+ let rewritten = 0;
93
+ for (const run of runs) {
94
+ if (run.kept) {
95
+ parts.push(oldSource.slice(oldBlocks[run.oldFrom].start, oldBlocks[run.oldTo - 1].end));
96
+ kept += run.oldTo - run.oldFrom;
97
+ continue;
98
+ }
99
+ // A changed run owns the gaps on both sides of it in the NEW source: the
100
+ // bytes from the end of the last kept new block to the start of the next
101
+ // one. At an edge of the document it stops at the edge block's own
102
+ // boundary; the head/tail beyond it is added once, outside this loop,
103
+ // under rule 4.
104
+ const from = run.newFrom > 0 ? newBlocks[run.newFrom - 1].end : newBlocks[0].start;
105
+ const to = run.newTo < newBlocks.length
106
+ ? newBlocks[run.newTo].start
107
+ : newBlocks[newBlocks.length - 1].end;
108
+ parts.push(newSource.slice(from, to));
109
+ rewritten += run.newTo - run.newFrom;
110
+ }
111
+ // The tail: everything after the last block, from whichever side owns it.
112
+ parts.push(runs[runs.length - 1].kept
113
+ ? oldSource.slice(oldBlocks[oldBlocks.length - 1].end)
114
+ : newSource.slice(newBlocks[newBlocks.length - 1].end));
115
+ return { source: parts.join(""), kept, rewritten, fallback: "none" };
116
+ }
117
+ /**
118
+ * Offsets have to be ascending, non-overlapping and inside the string, because
119
+ * the assembly above slices without re-checking and a bad list would silently
120
+ * produce scrambled bytes rather than an error. Callers feed these from a
121
+ * parser; a parser that ever emits a tree whose top-level children overlap is
122
+ * a bug we would rather see as a whole-document write than as a corrupted one.
123
+ */
124
+ function blocksAreUsable(blocks, length) {
125
+ let previousEnd = 0;
126
+ for (const block of blocks) {
127
+ if (!Number.isInteger(block.start) || !Number.isInteger(block.end)) {
128
+ return false;
129
+ }
130
+ if (block.start < previousEnd)
131
+ return false;
132
+ if (block.end < block.start)
133
+ return false;
134
+ if (block.end > length)
135
+ return false;
136
+ previousEnd = block.end;
137
+ }
138
+ return true;
139
+ }
140
+ /**
141
+ * Split both block lists into alternating kept/changed runs — the old↔new
142
+ * correspondence, on its own, with no bytes involved.
143
+ *
144
+ * Common prefix and suffix first — that is the entire diff for a normal edit
145
+ * and it costs one pass. What is left in the middle gets a longest-common-
146
+ * subsequence alignment so that two edits with untouched blocks between them
147
+ * keep those blocks' bytes; open-knowledge's version collapses that case into
148
+ * one over-wide splice, and it is the one thing here worth doing better than
149
+ * the reference, because "find and replace in three places" is an ordinary
150
+ * afternoon and it should not rewrite the paragraphs in between.
151
+ *
152
+ * Public because it is most of a rebinding ledger and it already runs on every
153
+ * write. `spliceBlocks` reads it for bytes; a caller that has derived an
154
+ * identity from the same keys reads it for what became what. Only `key` is
155
+ * consulted, so a caller holding keys and no offsets can ask this directly —
156
+ * the offset sanity `spliceBlocks` insists on is a slicing concern, not an
157
+ * alignment one.
158
+ */
159
+ export function alignBlockRuns(oldBlocks, newBlocks) {
160
+ const shorter = Math.min(oldBlocks.length, newBlocks.length);
161
+ let prefix = 0;
162
+ while (prefix < shorter && oldBlocks[prefix].key === newBlocks[prefix].key) {
163
+ prefix++;
164
+ }
165
+ let suffix = 0;
166
+ while (suffix < shorter - prefix &&
167
+ oldBlocks[oldBlocks.length - 1 - suffix].key ===
168
+ newBlocks[newBlocks.length - 1 - suffix].key) {
169
+ suffix++;
170
+ }
171
+ const runs = [];
172
+ if (prefix > 0) {
173
+ runs.push({ kept: true, oldFrom: 0, oldTo: prefix, newFrom: 0, newTo: prefix });
174
+ }
175
+ const oldMid = oldBlocks.slice(prefix, oldBlocks.length - suffix);
176
+ const newMid = newBlocks.slice(prefix, newBlocks.length - suffix);
177
+ const middle = alignMiddle(oldMid, newMid);
178
+ for (const run of middle.runs) {
179
+ runs.push({
180
+ kept: run.kept,
181
+ oldFrom: run.oldFrom + prefix,
182
+ oldTo: run.oldTo + prefix,
183
+ newFrom: run.newFrom + prefix,
184
+ newTo: run.newTo + prefix,
185
+ });
186
+ }
187
+ if (suffix > 0) {
188
+ runs.push({
189
+ kept: true,
190
+ oldFrom: oldBlocks.length - suffix,
191
+ oldTo: oldBlocks.length,
192
+ newFrom: newBlocks.length - suffix,
193
+ newTo: newBlocks.length,
194
+ });
195
+ }
196
+ return { runs: mergeAdjacent(runs), quality: middle.quality };
197
+ }
198
+ /** Runs for the region the prefix/suffix trim could not explain. */
199
+ function alignMiddle(oldMid, newMid) {
200
+ if (oldMid.length === 0 && newMid.length === 0) {
201
+ return { runs: [], quality: "exact" };
202
+ }
203
+ const whole = [
204
+ {
205
+ kept: false,
206
+ oldFrom: 0,
207
+ oldTo: oldMid.length,
208
+ newFrom: 0,
209
+ newTo: newMid.length,
210
+ },
211
+ ];
212
+ // A pure insertion or deletion in the middle: one side is empty, so there is
213
+ // nothing to match against and the single changed run IS the exact answer.
214
+ if (oldMid.length === 0 || newMid.length === 0) {
215
+ return { runs: whole, quality: "exact" };
216
+ }
217
+ // Past the budget the same shape means something weaker — "not looked at" —
218
+ // and the quality field is the only place that difference is recorded.
219
+ if (oldMid.length * newMid.length > ALIGNMENT_CELL_BUDGET) {
220
+ return { runs: whole, quality: "budget" };
221
+ }
222
+ return {
223
+ runs: runsFromPairs(longestCommonSubsequence(oldMid, newMid), oldMid.length, newMid.length),
224
+ quality: "exact",
225
+ };
226
+ }
227
+ /**
228
+ * Index pairs of a longest common subsequence of the two key sequences,
229
+ * ascending. Plain O(n·m) DP over an Int32Array — the budget above is what
230
+ * keeps that honest, and the keys are interned to integers first so the inner
231
+ * loop compares numbers instead of strings.
232
+ */
233
+ function longestCommonSubsequence(oldMid, newMid) {
234
+ const ids = new Map();
235
+ const intern = (key) => {
236
+ const seen = ids.get(key);
237
+ if (seen !== undefined)
238
+ return seen;
239
+ const id = ids.size;
240
+ ids.set(key, id);
241
+ return id;
242
+ };
243
+ const a = oldMid.map((block) => intern(block.key));
244
+ const b = newMid.map((block) => intern(block.key));
245
+ const width = b.length + 1;
246
+ const table = new Int32Array((a.length + 1) * width);
247
+ for (let i = a.length - 1; i >= 0; i--) {
248
+ for (let j = b.length - 1; j >= 0; j--) {
249
+ table[i * width + j] =
250
+ a[i] === b[j]
251
+ ? table[(i + 1) * width + j + 1] + 1
252
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
253
+ }
254
+ }
255
+ const pairs = [];
256
+ let i = 0;
257
+ let j = 0;
258
+ while (i < a.length && j < b.length) {
259
+ if (a[i] === b[j]) {
260
+ pairs.push([i, j]);
261
+ i++;
262
+ j++;
263
+ }
264
+ else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
265
+ i++;
266
+ }
267
+ else {
268
+ j++;
269
+ }
270
+ }
271
+ return pairs;
272
+ }
273
+ /** Turn matched index pairs into the alternating run list. */
274
+ function runsFromPairs(pairs, oldLength, newLength) {
275
+ const runs = [];
276
+ let oldAt = 0;
277
+ let newAt = 0;
278
+ for (const [oldIndex, newIndex] of pairs) {
279
+ if (oldIndex > oldAt || newIndex > newAt) {
280
+ runs.push({
281
+ kept: false,
282
+ oldFrom: oldAt,
283
+ oldTo: oldIndex,
284
+ newFrom: newAt,
285
+ newTo: newIndex,
286
+ });
287
+ }
288
+ runs.push({
289
+ kept: true,
290
+ oldFrom: oldIndex,
291
+ oldTo: oldIndex + 1,
292
+ newFrom: newIndex,
293
+ newTo: newIndex + 1,
294
+ });
295
+ oldAt = oldIndex + 1;
296
+ newAt = newIndex + 1;
297
+ }
298
+ if (oldAt < oldLength || newAt < newLength) {
299
+ runs.push({
300
+ kept: false,
301
+ oldFrom: oldAt,
302
+ oldTo: oldLength,
303
+ newFrom: newAt,
304
+ newTo: newLength,
305
+ });
306
+ }
307
+ return runs;
308
+ }
309
+ /**
310
+ * Fuse touching runs of the same kind.
311
+ *
312
+ * The LCS emits one run per matched block, and the prefix/suffix trim adds its
313
+ * own on both sides. Left as-is, two adjacent kept runs would each slice from
314
+ * their own first block's start, which DROPS the gap bytes between them —
315
+ * blank lines between untouched paragraphs would vanish on every save. Merging
316
+ * is not tidying; it is what makes rule 3 true.
317
+ */
318
+ function mergeAdjacent(runs) {
319
+ const merged = [];
320
+ for (const run of runs) {
321
+ const last = merged[merged.length - 1];
322
+ if (last && last.kept === run.kept) {
323
+ last.oldTo = run.oldTo;
324
+ last.newTo = run.newTo;
325
+ continue;
326
+ }
327
+ merged.push({ ...run });
328
+ }
329
+ return merged;
330
+ }
@@ -0,0 +1,81 @@
1
+ import type { FormatAxisId } from "../formatAxes.js";
2
+ import type { StructuredBlockLanguage } from "./structured-block-schema.js";
3
+ /**
4
+ * Every reason a structured-block parser drops bytes. Adding a case here
5
+ * without adding it to {@link BLOCK_DROP_ADJUDICATIONS} does not compile —
6
+ * that is the "no unadjudicated drop" half of the closure.
7
+ */
8
+ export type BlockDropReason = "blank-line" | "block-unread" | "entry-bullet-marker" | "status:unsigned-line" | "board:unparsed-line" | "chat:unparsed-line" | "sheet:non-row-line" | "sheet:delimiter-row" | "sheet:excess-cells" | "map:unparsed-line";
9
+ /** A block body that provokes a drop, and the exact bytes it loses. */
10
+ export interface DropWitness {
11
+ lang: StructuredBlockLanguage;
12
+ /** The fence body — what the parser is handed. */
13
+ source: string;
14
+ /** The dropped text the accounting must report, verbatim. */
15
+ dropped: string;
16
+ }
17
+ export type DropAdjudication = {
18
+ kind: "structural-only";
19
+ witness: DropWitness;
20
+ rationale: string;
21
+ /** True when the line still rendered and only part of it was lost. */
22
+ partial?: true;
23
+ } | {
24
+ kind: "format-dof-axis";
25
+ /**
26
+ * Ids from `../formatAxes`, not free text. Card #288 turned the axis
27
+ * vocabulary into a closed union precisely so this field cannot name a
28
+ * spelling nobody defined — before it, `format-dof-axis` was a verdict
29
+ * that pointed at a string, which is a shrug with a citation on it.
30
+ * `__tests__/formatAxes.test.ts` closes the reference the other way too:
31
+ * an axis no ledger entry spends is stale and fails.
32
+ */
33
+ axisIds: readonly FormatAxisId[];
34
+ witness: DropWitness;
35
+ rationale: string;
36
+ partial?: true;
37
+ } | {
38
+ kind: "retained-by-capture";
39
+ witness: DropWitness;
40
+ /** Where the bytes went, as a path into the parsed data. */
41
+ retained: string;
42
+ rationale: string;
43
+ partial?: true;
44
+ } | {
45
+ kind: "documented-residual";
46
+ witnesses: readonly DropWitness[];
47
+ rationale: string;
48
+ partial?: true;
49
+ };
50
+ export declare const BLOCK_DROP_ADJUDICATIONS: Readonly<Record<BlockDropReason, DropAdjudication>>;
51
+ /**
52
+ * True when the drop takes the whole line with it. A partial drop happened on
53
+ * a line that rendered anyway — the bullet marker in front of a chat entry,
54
+ * the cells past the end of a sheet's header — and must NOT be subtracted from
55
+ * the consumed set.
56
+ */
57
+ export declare function isWholeLineDrop(reason: BlockDropReason): boolean;
58
+ /**
59
+ * True when an author should hear about the drop. Only a documented residual
60
+ * is real loss; the other three verdicts say the bytes were structure, a
61
+ * spelling, or captured elsewhere, and a diagnostic for those would be noise
62
+ * on a block that rendered exactly as written.
63
+ */
64
+ export declare function isReportableDrop(reason: BlockDropReason): boolean;
65
+ /** Every witness in the ledger, flattened, with the reason it belongs to. */
66
+ export declare function dropWitnesses(): Array<{
67
+ reason: BlockDropReason;
68
+ witness: DropWitness;
69
+ }>;
70
+ export interface ClosureCheckResult {
71
+ /** Reasons the parsers produced that the ledger does not adjudicate. */
72
+ unadjudicated: string[];
73
+ /** Ledger entries no fixture provokes any more. */
74
+ stale: string[];
75
+ }
76
+ /**
77
+ * Close the ledger against what the parsers actually did. `observed` is every
78
+ * reason seen across the whole fixture corpus — one document's drops are never
79
+ * enough to close anything.
80
+ */
81
+ export declare function checkDropClosure(observed: readonly string[]): ClosureCheckResult;