sfora-cli 0.8.0 → 0.10.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 (71) hide show
  1. package/README.md +8 -6
  2. package/dist/SforaFs.js +270 -4
  3. package/dist/api-client.d.ts +47 -1
  4. package/dist/api-client.js +60 -3
  5. package/dist/cli.js +24 -11
  6. package/dist/format/__tests__/byteStable.d.ts +5 -0
  7. package/dist/format/__tests__/byteStable.js +64 -0
  8. package/dist/format/blocks/dropClosure.d.ts +72 -0
  9. package/dist/format/blocks/dropClosure.js +186 -0
  10. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  11. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  12. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  13. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  14. package/dist/format/blocks/parsers.d.ts +105 -0
  15. package/dist/format/blocks/parsers.js +442 -0
  16. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  17. package/dist/format/blocks/structured-block-schema.js +30 -0
  18. package/dist/format/callout.d.ts +66 -0
  19. package/dist/format/callout.js +130 -0
  20. package/dist/format/cardMarkdown.d.ts +4 -0
  21. package/dist/format/cardMarkdown.js +12 -0
  22. package/dist/format/checklist.d.ts +34 -0
  23. package/dist/format/checklist.js +151 -0
  24. package/dist/format/index.d.ts +18 -4
  25. package/dist/format/index.js +24 -4
  26. package/dist/format/lineGeometry.d.ts +70 -0
  27. package/dist/format/lineGeometry.js +324 -0
  28. package/dist/format/lint/index.d.ts +20 -0
  29. package/dist/format/lint/index.js +22 -0
  30. package/dist/format/lint/lintSource.d.ts +36 -0
  31. package/dist/format/lint/lintSource.js +154 -0
  32. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  33. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  34. package/dist/format/lint/rules/index.d.ts +10 -0
  35. package/dist/format/lint/rules/index.js +26 -0
  36. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  37. package/dist/format/lint/rules/malformed-callout.js +79 -0
  38. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  39. package/dist/format/lint/rules/malformed-checklist.js +60 -0
  40. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  41. package/dist/format/lint/rules/malformed-frontmatter.js +93 -0
  42. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  43. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  44. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  45. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  46. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  47. package/dist/format/lint/rules/orphan-reference.js +87 -0
  48. package/dist/format/lint/types.d.ts +80 -0
  49. package/dist/format/lint/types.js +16 -0
  50. package/dist/format/markdown/dates.js +2 -0
  51. package/dist/format/markdown/document.js +2 -0
  52. package/dist/format/markdown/index.js +2 -0
  53. package/dist/format/markdown/mentions.js +2 -0
  54. package/dist/format/markdown/slug.js +2 -0
  55. package/dist/format/markdown/yaml.js +2 -0
  56. package/dist/format/noteMarkdown.js +2 -0
  57. package/dist/format/parseWithFallback.d.ts +13 -0
  58. package/dist/format/parseWithFallback.js +98 -0
  59. package/dist/format/plaintext.d.ts +5 -0
  60. package/dist/format/plaintext.js +41 -0
  61. package/dist/format/postMarkdown.js +3 -1
  62. package/dist/format/taskUploadFilename.d.ts +6 -0
  63. package/dist/format/taskUploadFilename.js +13 -0
  64. package/dist/format/wayfinder.d.ts +50 -0
  65. package/dist/format/wayfinder.js +203 -0
  66. package/dist/format/wikiLinks.d.ts +19 -0
  67. package/dist/format/wikiLinks.js +80 -0
  68. package/dist/local/workspace.d.ts +12 -0
  69. package/dist/local/workspace.js +100 -7
  70. package/dist/mcp-server.js +11 -5
  71. package/package.json +7 -6
@@ -0,0 +1,324 @@
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
+ // Line geometry: where the fences and the frontmatter are, and which bytes
4
+ // never reach a reader at all.
5
+ //
6
+ // Lint built this first and is still its heaviest user, but it is not lint's
7
+ // to own — a checkbox-looking line inside a fence is code to the reader, to
8
+ // `checklistProgress`, and to every other tool that walks lines instead of the
9
+ // tree. Those callers must not carry a second, subtly different idea of where
10
+ // a fence starts, so the scan lives here and `lint/lintSource` re-exports it.
11
+ //
12
+ // The second half of the file is the NON-RENDERING MASK: one same-length
13
+ // string in which every byte that does not render — fenced code, inline code
14
+ // spans, HTML comments, raw `<pre>`/`<code>` — has been replaced by a space.
15
+ // Substitution, never deletion, so offset N in the mask is offset N in the
16
+ // source by construction and a line scanner and the parsed tree can never
17
+ // disagree about which bytes are markdown. Every scanner in the package reads
18
+ // it; there is no second copy.
19
+ //
20
+ // Pure line arithmetic: no parser, no dependencies. It ships in the CLI.
21
+ // The optional BOM only ever occurs on the first line, and allowing it here is
22
+ // not cosmetic: without it a file that opens with a fence has no fence at all,
23
+ // and every rule that skips fenced code would walk straight into one.
24
+ const FENCE_OPEN = /^?( {0,3})(`{3,}|~{3,})(.*)$/;
25
+ /** The line without its trailing `\r`, so `$`-anchored rules work on CRLF. */
26
+ export function lineText(lines, i) {
27
+ const raw = lines[i] ?? "";
28
+ return raw.endsWith("\r") ? raw.slice(0, -1) : raw;
29
+ }
30
+ /**
31
+ * Frontmatter extent, or null. Only a document that OPENS with `---` has
32
+ * frontmatter — a `---` further down is a thematic break, and treating it as a
33
+ * fence would make every horizontal rule the start of a lint dead zone.
34
+ */
35
+ export function frontmatterExtent(lines) {
36
+ if (lines.length === 0)
37
+ return null;
38
+ const first = lineText(lines, 0).replace(/^/, "");
39
+ if (first !== "---")
40
+ return null;
41
+ for (let i = 1; i < lines.length; i++) {
42
+ const text = lineText(lines, i).trimEnd();
43
+ if (text === "---" || text === "...")
44
+ return { open: 0, close: i };
45
+ }
46
+ return { open: 0, close: -1 }; // opened, never closed
47
+ }
48
+ /**
49
+ * Every fenced code block in the document, in order. `from` skips the
50
+ * frontmatter block, whose contents are not markdown either.
51
+ */
52
+ export function fencedRegions(lines, from = 0) {
53
+ const regions = [];
54
+ for (let i = from; i < lines.length; i++) {
55
+ const match = FENCE_OPEN.exec(lineText(lines, i));
56
+ if (!match)
57
+ continue;
58
+ const delimiter = match[2];
59
+ const info = match[3];
60
+ // A backtick fence's info string may not contain a backtick — that rules
61
+ // out inline code like ```` ```a``` ```` opening a block.
62
+ if (delimiter.startsWith("`") && info.includes("`"))
63
+ continue;
64
+ const char = delimiter[0];
65
+ const closer = new RegExp(`^ {0,3}[${char}]{${delimiter.length},}[ \\t]*$`);
66
+ let close = lines.length - 1;
67
+ let closed = false;
68
+ for (let j = i + 1; j < lines.length; j++) {
69
+ if (closer.test(lineText(lines, j))) {
70
+ close = j;
71
+ closed = true;
72
+ break;
73
+ }
74
+ }
75
+ regions.push({
76
+ open: i,
77
+ close,
78
+ closed,
79
+ lang: info.trim().split(/\s+/)[0]?.toLowerCase() ?? "",
80
+ indent: match[1].length,
81
+ contentStart: i + 1,
82
+ contentEnd: closed ? close : lines.length,
83
+ });
84
+ i = close;
85
+ }
86
+ return regions;
87
+ }
88
+ /** True for every line covered by a fenced block, delimiters included. */
89
+ export function fenceMask(lines, from = 0) {
90
+ const mask = new Array(lines.length).fill(false);
91
+ for (const region of fencedRegions(lines, from)) {
92
+ for (let i = region.open; i <= region.close; i++)
93
+ mask[i] = true;
94
+ }
95
+ return mask;
96
+ }
97
+ // ── The non-rendering mask ──────────────────────────────────────────────────
98
+ /**
99
+ * One past the closing backtick run of the code span opening at `start`, or
100
+ * null when the run never closes.
101
+ *
102
+ * CommonMark wants the closing run to be EXACTLY as long as the opening one,
103
+ * which is why this is a scan and not an `indexOf`: in `` `a``b` `` the double
104
+ * run does not close the single, and a search for "one or more backticks"
105
+ * ends the span three characters early. An unterminated run opens nothing —
106
+ * a stray backtick must not swallow the rest of the line.
107
+ */
108
+ function codeSpanEnd(text, start) {
109
+ let open = 0;
110
+ while (text[start + open] === "`")
111
+ open++;
112
+ if (open === 0)
113
+ return null;
114
+ let i = start + open;
115
+ while (i < text.length) {
116
+ if (text[i] !== "`") {
117
+ i++;
118
+ continue;
119
+ }
120
+ let run = 0;
121
+ while (text[i + run] === "`")
122
+ run++;
123
+ if (run === open)
124
+ return i + run;
125
+ i += run;
126
+ }
127
+ return null;
128
+ }
129
+ /**
130
+ * True where a line is inside an inline code span, delimiters included.
131
+ *
132
+ * Rules and parsers mask their matches against this so a token quoted as
133
+ * `` `[[c:1]]` `` — the way documentation talks ABOUT the grammar — is never
134
+ * read as using it, and so a pipe inside `` `a|b` `` is content rather than a
135
+ * cell boundary. Line-local by design: a code span cannot cross a blank line,
136
+ * and the callers hold one line at a time.
137
+ */
138
+ export function codeSpanMask(line) {
139
+ const mask = new Array(line.length).fill(false);
140
+ let i = 0;
141
+ while (i < line.length) {
142
+ if (line[i] !== "`") {
143
+ i++;
144
+ continue;
145
+ }
146
+ const end = codeSpanEnd(line, i);
147
+ if (end === null) {
148
+ while (line[i] === "`")
149
+ i++;
150
+ continue;
151
+ }
152
+ for (let k = i; k < end; k++)
153
+ mask[k] = true;
154
+ i = end;
155
+ }
156
+ return mask;
157
+ }
158
+ /** The raw-text tag opening at `start`, or null. */
159
+ function rawTextTagAt(line, start) {
160
+ if (line[start] !== "<")
161
+ return null;
162
+ const lower = line.slice(start, start + 6).toLowerCase();
163
+ for (const tag of ["pre", "code"]) {
164
+ if (!lower.startsWith(`<${tag}`))
165
+ continue;
166
+ // `<pre>`, `<pre/>`, `<pre class=…>` — but not `<precise`.
167
+ const boundary = line[start + tag.length + 1];
168
+ if (boundary === ">" || boundary === "/" || boundary === " " || boundary === "\t") {
169
+ return tag;
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+ /** One past the `>` closing the tag that opens at `start`, or null. */
175
+ function htmlTagEnd(line, start) {
176
+ let quote = null;
177
+ for (let i = start; i < line.length; i++) {
178
+ const char = line[i];
179
+ if (quote !== null) {
180
+ if (char === quote)
181
+ quote = null;
182
+ }
183
+ else if (char === '"' || char === "'") {
184
+ quote = char;
185
+ }
186
+ else if (char === ">") {
187
+ return i + 1;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+ /** One past `</pre>` / `</code>` at or after `start`, or null. */
193
+ function rawTextCloseEnd(line, start, tag) {
194
+ const match = new RegExp(`</${tag}\\s*>`, "i").exec(line.slice(start));
195
+ return match === null ? null : start + match.index + match[0].length;
196
+ }
197
+ /**
198
+ * Blank one line's non-rendering spans, carrying `state` forward.
199
+ *
200
+ * Order matters: a backtick inside a comment is not a code span, and a `<!--`
201
+ * inside a code span is not a comment, so whichever construct opens first owns
202
+ * everything up to its own close. Left open at the end of the line, a comment
203
+ * or a raw tag keeps consuming into the lines below — which is what an
204
+ * unterminated `<!--` does to a reader too.
205
+ */
206
+ function maskLine(raw, state) {
207
+ const chars = raw.split("");
208
+ const blank = (from, to) => {
209
+ for (let i = from; i < to; i++)
210
+ chars[i] = " ";
211
+ };
212
+ let i = 0;
213
+ while (i < raw.length) {
214
+ if (state.comment) {
215
+ const close = raw.indexOf("-->", i);
216
+ blank(i, close < 0 ? raw.length : close + 3);
217
+ if (close < 0)
218
+ break;
219
+ state.comment = false;
220
+ i = close + 3;
221
+ continue;
222
+ }
223
+ if (state.rawTag !== null) {
224
+ const end = rawTextCloseEnd(raw, i, state.rawTag);
225
+ blank(i, end ?? raw.length);
226
+ if (end === null)
227
+ break;
228
+ state.rawTag = null;
229
+ i = end;
230
+ continue;
231
+ }
232
+ const char = raw[i];
233
+ if (char === "`") {
234
+ const end = codeSpanEnd(raw, i);
235
+ if (end === null) {
236
+ while (raw[i] === "`")
237
+ i++;
238
+ continue;
239
+ }
240
+ blank(i, end);
241
+ i = end;
242
+ continue;
243
+ }
244
+ if (char === "<") {
245
+ if (raw.startsWith("<!--", i)) {
246
+ // CommonMark 0.31 admits the two degenerate spellings `<!-->` and
247
+ // `<!--->` as complete comments. Without them the `-->` search runs
248
+ // off the end, the comment is read as unterminated, and everything
249
+ // after it in the document is masked on the strength of five bytes.
250
+ const short = raw.startsWith("<!--->", i)
251
+ ? 6
252
+ : raw.startsWith("<!-->", i)
253
+ ? 5
254
+ : 0;
255
+ if (short > 0) {
256
+ blank(i, i + short);
257
+ i += short;
258
+ continue;
259
+ }
260
+ const close = raw.indexOf("-->", i + 4);
261
+ blank(i, close < 0 ? raw.length : close + 3);
262
+ state.comment = close < 0;
263
+ if (state.comment)
264
+ break;
265
+ i = close + 3;
266
+ continue;
267
+ }
268
+ const tag = rawTextTagAt(raw, i);
269
+ if (tag !== null) {
270
+ const openEnd = htmlTagEnd(raw, i);
271
+ // An opening tag that runs past the end of the line: nothing after it
272
+ // on this line can be read with any confidence, so stop here.
273
+ if (openEnd === null)
274
+ break;
275
+ const closeEnd = rawTextCloseEnd(raw, openEnd, tag);
276
+ blank(i, closeEnd ?? raw.length);
277
+ state.rawTag = closeEnd === null ? tag : null;
278
+ if (closeEnd === null)
279
+ break;
280
+ i = closeEnd;
281
+ continue;
282
+ }
283
+ }
284
+ i++;
285
+ }
286
+ return chars.join("");
287
+ }
288
+ /**
289
+ * The non-rendering mask, line by line. Each returned line has exactly the
290
+ * length of the line it masks, so `masked[i][k]` and `lines[i][k]` are the
291
+ * same byte position.
292
+ *
293
+ * `from` skips a leading frontmatter block for the FENCE scan only, mirroring
294
+ * {@link fenceMask} — whether the frontmatter itself renders is the caller's
295
+ * question, and lint already answers it with `inFrontmatter`.
296
+ */
297
+ export function maskNonRenderingLines(lines, from = 0) {
298
+ const fenced = fenceMask(lines, from);
299
+ const state = { comment: false, rawTag: null };
300
+ return lines.map((raw, i) => fenced[i] ? " ".repeat(raw.length) : maskLine(raw, state));
301
+ }
302
+ /**
303
+ * The whole source, masked. `masked.length === source.length` always: the two
304
+ * strings are the same document, one of them with the non-markdown blanked
305
+ * out, and every offset holds across both.
306
+ */
307
+ export function maskNonRenderingContexts(source) {
308
+ return maskNonRenderingLines(source.split("\n")).join("\n");
309
+ }
310
+ /**
311
+ * True when the source span `[start, end)` holds bytes but no rendering ones —
312
+ * the predicate a consumer asks before trusting anything it found by scanning.
313
+ *
314
+ * A span that was blank to begin with is not "non-rendering", it is empty, and
315
+ * answering true for it would demote every piece of whitespace in the
316
+ * document.
317
+ */
318
+ export function isNonRenderingRange(source, masked, start, end) {
319
+ if (end <= start)
320
+ return false;
321
+ if (source.slice(start, end).trim() === "")
322
+ return false;
323
+ return masked.slice(start, end).trim() === "";
324
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * sfora-law: the rules a sfora markdown document is held to, and the quick
3
+ * fixes that satisfy them.
4
+ *
5
+ * Not a style checker. Every rule answers one question — "will this byte
6
+ * sequence render as the author obviously meant it to?" — and every rule that
7
+ * cannot answer with certainty says nothing. There is no line length, no
8
+ * heading order, no trailing whitespace. Markdown is the user's file; lint is
9
+ * only allowed to point at places where sfora will read it differently than
10
+ * they will.
11
+ *
12
+ * The core is pure: no CodeMirror, no unified. Diagnostics carry absolute
13
+ * offsets and the editor adapter (src/components/notes/cm-lint.ts) translates
14
+ * them; an mdast tree, when one is wanted, is injected. That is what lets the
15
+ * same rules run in the CLI before an agent PUTs a file as run in the editor
16
+ * while a human types it.
17
+ */
18
+ export * from "./types.js";
19
+ export * from "./lintSource.js";
20
+ export * from "./rules/index.js";
@@ -0,0 +1,22 @@
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
+ /**
4
+ * sfora-law: the rules a sfora markdown document is held to, and the quick
5
+ * fixes that satisfy them.
6
+ *
7
+ * Not a style checker. Every rule answers one question — "will this byte
8
+ * sequence render as the author obviously meant it to?" — and every rule that
9
+ * cannot answer with certainty says nothing. There is no line length, no
10
+ * heading order, no trailing whitespace. Markdown is the user's file; lint is
11
+ * only allowed to point at places where sfora will read it differently than
12
+ * they will.
13
+ *
14
+ * The core is pure: no CodeMirror, no unified. Diagnostics carry absolute
15
+ * offsets and the editor adapter (src/components/notes/cm-lint.ts) translates
16
+ * them; an mdast tree, when one is wanted, is injected. That is what lets the
17
+ * same rules run in the CLI before an agent PUTs a file as run in the editor
18
+ * while a human types it.
19
+ */
20
+ export * from "./types.js";
21
+ export * from "./lintSource.js";
22
+ export * from "./rules/index.js";
@@ -0,0 +1,36 @@
1
+ import { type WikiLink } from "../wikiLinks.js";
2
+ import type { LintContext, LintFix, LintSourceOptions, SforaDiagnostic } from "./types.js";
3
+ export { codeSpanMask, fenceMask, fencedRegions, frontmatterExtent, isNonRenderingRange, lineText, maskNonRenderingContexts, maskNonRenderingLines, type FenceRegion, } from "../lineGeometry.js";
4
+ /** Lines a prose rule must not look at: fences and the frontmatter block. */
5
+ export declare function isSkippableLine(ctx: LintContext, i: number): boolean;
6
+ /** True when a `[` at `index` is escaped as `\[`. */
7
+ export declare function isEscaped(line: string, index: number): boolean;
8
+ export interface ScannedWikiToken extends WikiLink {
9
+ lineIndex: number;
10
+ /** Absolute span of the whole `[[…]]` token. */
11
+ from: number;
12
+ to: number;
13
+ }
14
+ /**
15
+ * Every `[[…]]` token outside fences, frontmatter and inline code.
16
+ *
17
+ * The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
18
+ * needs a non-empty body and happily spans newlines. Lint needs the opposite
19
+ * on both counts — it has to see `[[]]` to complain about it, and a `[[` left
20
+ * open at the end of a line is someone mid-typing, not a link. So the span
21
+ * matcher is local and one line wide; the meaning of what is inside the
22
+ * brackets still comes from `parseWikiToken`, which owns the prefix table.
23
+ */
24
+ export declare function scanWikiTokens(ctx: LintContext): ScannedWikiToken[];
25
+ export declare function buildLintContext(source: string, opts?: LintSourceOptions): LintContext;
26
+ /**
27
+ * Run the rules over a document. Diagnostics come back in document order; a
28
+ * rule that throws contributes none and does not take the others down with it.
29
+ */
30
+ export declare function lintSource(source: string, opts?: LintSourceOptions): SforaDiagnostic[];
31
+ /**
32
+ * Apply a fix to the source it was computed against. Edits are applied last
33
+ * one first so earlier offsets stay valid — the same reason the editor
34
+ * dispatches them as one transaction.
35
+ */
36
+ export declare function applyLintFix(source: string, fix: LintFix): string;
@@ -0,0 +1,154 @@
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 sfora-law runner, plus the document geometry every rule shares.
4
+ //
5
+ // One pass builds a LintContext — line starts, the fence map, the frontmatter
6
+ // extent — and each rule reads it. Nothing here throws: a parser that blows up
7
+ // costs the AST, a rule that blows up costs that rule's diagnostics, and the
8
+ // caller still gets a list. Lint is a background nicety in a text editor; it
9
+ // is never allowed to be the reason a document will not open.
10
+ import { codeSpanMask, fenceMask, frontmatterExtent, lineText, } from "../lineGeometry.js";
11
+ import { MAX_PARSE_INPUT_BYTES } from "../parseWithFallback.js";
12
+ import { parseWikiToken } from "../wikiLinks.js";
13
+ import { SFORA_LINT_RULES } from "./rules/index.js";
14
+ // The fence/frontmatter geometry moved to ../lineGeometry once a second
15
+ // caller (checklist.ts) needed it, and `codeSpanMask` followed it there once a
16
+ // third (blocks/parsers.ts) had hand-copied it. Re-exported so the rules — and
17
+ // everything importing through lint/index, the Convex shim included — keep
18
+ // their existing import path.
19
+ export { codeSpanMask, fenceMask, fencedRegions, frontmatterExtent, isNonRenderingRange, lineText, maskNonRenderingContexts, maskNonRenderingLines, } from "../lineGeometry.js";
20
+ /** Lines a prose rule must not look at: fences and the frontmatter block. */
21
+ export function isSkippableLine(ctx, i) {
22
+ return ctx.inFence(i) || ctx.inFrontmatter(i);
23
+ }
24
+ /** True when a `[` at `index` is escaped as `\[`. */
25
+ export function isEscaped(line, index) {
26
+ let slashes = 0;
27
+ for (let i = index - 1; i >= 0 && line[i] === "\\"; i--)
28
+ slashes++;
29
+ return slashes % 2 === 1;
30
+ }
31
+ /**
32
+ * Every `[[…]]` token outside fences, frontmatter and inline code.
33
+ *
34
+ * The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
35
+ * needs a non-empty body and happily spans newlines. Lint needs the opposite
36
+ * on both counts — it has to see `[[]]` to complain about it, and a `[[` left
37
+ * open at the end of a line is someone mid-typing, not a link. So the span
38
+ * matcher is local and one line wide; the meaning of what is inside the
39
+ * brackets still comes from `parseWikiToken`, which owns the prefix table.
40
+ */
41
+ export function scanWikiTokens(ctx) {
42
+ const out = [];
43
+ for (let i = 0; i < ctx.lines.length; i++) {
44
+ if (isSkippableLine(ctx, i))
45
+ continue;
46
+ const line = lineText(ctx.lines, i);
47
+ const mask = codeSpanMask(line);
48
+ const re = /\[\[([^[\]\n]*)\]\]/g;
49
+ let m;
50
+ while ((m = re.exec(line)) !== null) {
51
+ if (mask[m.index] || isEscaped(line, m.index))
52
+ continue;
53
+ const from = ctx.lineStart(i) + m.index;
54
+ out.push({
55
+ ...parseWikiToken(m[1]),
56
+ lineIndex: i,
57
+ from,
58
+ to: from + m[0].length,
59
+ });
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ export function buildLintContext(source, opts = {}) {
65
+ const lines = source.split("\n");
66
+ const starts = new Array(lines.length);
67
+ let offset = 0;
68
+ for (let i = 0; i < lines.length; i++) {
69
+ starts[i] = offset;
70
+ offset += lines[i].length + 1;
71
+ }
72
+ const frontmatter = frontmatterExtent(lines);
73
+ const fenceScanFrom = frontmatter === null
74
+ ? 0
75
+ : frontmatter.close === -1
76
+ ? lines.length
77
+ : frontmatter.close + 1;
78
+ const fenced = fenceMask(lines, fenceScanFrom);
79
+ const fmEnd = frontmatter === null
80
+ ? -1
81
+ : frontmatter.close === -1
82
+ ? lines.length - 1
83
+ : frontmatter.close;
84
+ return {
85
+ source,
86
+ lines,
87
+ lineStart: (i) => starts[i] ?? source.length,
88
+ inFence: (i) => fenced[i] === true,
89
+ inFrontmatter: (i) => frontmatter !== null && i >= 0 && i <= fmEnd,
90
+ frontmatter,
91
+ ast: parseAstSafely(source, opts.parseAst),
92
+ resolveLink: opts.resolveLink,
93
+ };
94
+ }
95
+ // A throwing parser costs the AST and nothing else. The size cap is the same
96
+ // one parseWithFallback uses: past it we are looking at a paste accident, and
97
+ // no lint mark is worth parsing four megabytes on a keystroke.
98
+ //
99
+ // A leading BOM costs it too. `parseMarkdownAst` strips one before parsing, so
100
+ // every offset in the tree it returns is one short of the source the rules
101
+ // hold — and a rule that mixes the two paints its squiggle one character off.
102
+ // A document that opens with a BOM is rare enough, and the AST optional
103
+ // enough, that declining is the honest answer.
104
+ function parseAstSafely(source, parseAst) {
105
+ if (!parseAst || source.length > MAX_PARSE_INPUT_BYTES)
106
+ return undefined;
107
+ if (source.charCodeAt(0) === 0xfeff)
108
+ return undefined;
109
+ try {
110
+ return parseAst(source);
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /**
117
+ * Run the rules over a document. Diagnostics come back in document order; a
118
+ * rule that throws contributes none and does not take the others down with it.
119
+ */
120
+ export function lintSource(source, opts = {}) {
121
+ const ctx = buildLintContext(source, opts);
122
+ const rules = opts.rules ?? SFORA_LINT_RULES;
123
+ const out = [];
124
+ for (const rule of rules) {
125
+ if (rule.needsAst && !ctx.ast)
126
+ continue;
127
+ try {
128
+ out.push(...rule.run(ctx));
129
+ }
130
+ catch {
131
+ // A broken rule is a missing mark, never a broken editor.
132
+ }
133
+ }
134
+ return out.sort((a, b) => a.from !== b.from
135
+ ? a.from - b.from
136
+ : a.to !== b.to
137
+ ? a.to - b.to
138
+ : a.ruleId.localeCompare(b.ruleId));
139
+ }
140
+ /**
141
+ * Apply a fix to the source it was computed against. Edits are applied last
142
+ * one first so earlier offsets stay valid — the same reason the editor
143
+ * dispatches them as one transaction.
144
+ */
145
+ export function applyLintFix(source, fix) {
146
+ const edits = [...fix.edits].sort((a, b) => b.from - a.from);
147
+ let out = source;
148
+ for (const edit of edits) {
149
+ if (edit.from < 0 || edit.to > out.length || edit.from > edit.to)
150
+ continue;
151
+ out = out.slice(0, edit.from) + edit.insert + out.slice(edit.to);
152
+ }
153
+ return out;
154
+ }
@@ -0,0 +1,2 @@
1
+ import type { LintRule } from "../types.js";
2
+ export declare const brokenWikiLinkRule: LintRule;
@@ -0,0 +1,45 @@
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
+ // `[[c:42|Fix login]]` pointing at something that is not there.
4
+ //
5
+ // The whole rule turns on one asymmetry: a resolver that says `{exists: false}`
6
+ // KNOWS the target is gone, while a resolver that returns `undefined` has not
7
+ // looked yet (or does not recognize the prefix). Only the first is a
8
+ // diagnostic. That is what keeps a freshly opened document from painting every
9
+ // link dead for the half second before resolution lands.
10
+ import { isSkippableLine, scanWikiTokens } from "../lintSource.js";
11
+ export const brokenWikiLinkRule = {
12
+ id: "sfora/broken-wiki-link",
13
+ run(ctx) {
14
+ const resolve = ctx.resolveLink;
15
+ if (!resolve)
16
+ return [];
17
+ const out = [];
18
+ for (const token of scanWikiTokens(ctx)) {
19
+ // Empty and prefix-only targets are the malformed rule's business.
20
+ if (token.target === "" || token.id === "")
21
+ continue;
22
+ if (isSkippableLine(ctx, token.lineIndex))
23
+ continue;
24
+ if (resolve(token.target)?.exists !== false)
25
+ continue;
26
+ out.push({
27
+ from: token.from,
28
+ to: token.to,
29
+ severity: "warning",
30
+ ruleId: "sfora/broken-wiki-link",
31
+ message: `Nothing here links to \`${token.target}\` — it was deleted, or you don't have access.`,
32
+ // No "create the target" fix: this rule cannot know what the missing
33
+ // thing was meant to be, and inventing one would be a worse guess than
34
+ // leaving the sentence readable.
35
+ fixes: [
36
+ {
37
+ label: `Remove link, keep "${token.label}"`,
38
+ edits: [{ from: token.from, to: token.to, insert: token.label }],
39
+ },
40
+ ],
41
+ });
42
+ }
43
+ return out;
44
+ },
45
+ };
@@ -0,0 +1,10 @@
1
+ import { brokenWikiLinkRule } from "./broken-wiki-link.js";
2
+ import { malformedCalloutRule } from "./malformed-callout.js";
3
+ import { malformedChecklistRule } from "./malformed-checklist.js";
4
+ import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
5
+ import { malformedStructuredBlockRule } from "./malformed-structured-block.js";
6
+ import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
7
+ import { orphanReferenceRule } from "./orphan-reference.js";
8
+ import type { LintRule } from "../types.js";
9
+ export declare const SFORA_LINT_RULES: readonly LintRule[];
10
+ export { brokenWikiLinkRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
@@ -0,0 +1,26 @@
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 rule registry — sfora-law in the order its marks are read.
4
+ //
5
+ // Kept beside the rules rather than in lint/index.ts so the runner can import
6
+ // it without the barrel importing the runner: one direction of dependency, no
7
+ // module cycle, no half-initialized array at load time.
8
+ //
9
+ // Adding a rule is adding a file and a line here. Nothing else knows the list.
10
+ import { brokenWikiLinkRule } from "./broken-wiki-link.js";
11
+ import { malformedCalloutRule } from "./malformed-callout.js";
12
+ import { malformedChecklistRule } from "./malformed-checklist.js";
13
+ import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
14
+ import { malformedStructuredBlockRule } from "./malformed-structured-block.js";
15
+ import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
16
+ import { orphanReferenceRule } from "./orphan-reference.js";
17
+ export const SFORA_LINT_RULES = [
18
+ brokenWikiLinkRule,
19
+ malformedWikiLinkRule,
20
+ malformedCalloutRule,
21
+ malformedStructuredBlockRule,
22
+ orphanReferenceRule,
23
+ malformedChecklistRule,
24
+ malformedFrontmatterRule,
25
+ ];
26
+ export { brokenWikiLinkRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
@@ -0,0 +1,2 @@
1
+ import type { LintRule } from "../types.js";
2
+ export declare const malformedCalloutRule: LintRule;