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,49 @@
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, no third-party import at all.
13
+ * Diagnostics carry absolute offsets and the editor adapter
14
+ * (src/components/notes/cm-lint.ts) translates them; an mdast tree, when one
15
+ * is wanted, is injected. That is what lets the same rules run in the CLI
16
+ * before an agent PUTs a file as run in the editor while a human types it.
17
+ *
18
+ * Five modules underneath the rules carry the mechanics, and all five exist so
19
+ * the same seven rules can be USED in more places rather than so there can be
20
+ * more of them:
21
+ *
22
+ * ./severity the four levels and what each one means. The ladder is by
23
+ * what the document LOSES, not by how loud a rule feels.
24
+ * ./textEdits a fix is a list of ranged replacements over one document
25
+ * state, applied reverse-sorted, all of it or none of it.
26
+ * ./fixAll every fix in a document composed into one atomic change,
27
+ * with an account of what was applied, refused and left.
28
+ * ./appliesTo the path-glob dialect a scope is written in, and the
29
+ * judgement that one of those globs is doubtful.
30
+ * ./config what a workspace may say about these rules — off, another
31
+ * severity, a path scope — and the linter that reads its own
32
+ * config back and flags the settings that silently cost it a
33
+ * rule.
34
+ * ./frontmatterSchema
35
+ * the one dial that adds a check: a JSON Schema a workspace
36
+ * declares for a kind of document, read against sfora's own
37
+ * frontmatter value model, off until it is written down — and
38
+ * linted itself, so a keyword sfora does not implement is
39
+ * reported rather than quietly enforcing nothing.
40
+ */
41
+ export * from "./types.js";
42
+ export * from "./severity.js";
43
+ export * from "./textEdits.js";
44
+ export * from "./appliesTo.js";
45
+ export * from "./config.js";
46
+ export * from "./frontmatterSchema.js";
47
+ export * from "./lintSource.js";
48
+ export * from "./fixAll.js";
49
+ export * from "./rules/index.js";
@@ -0,0 +1,51 @@
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, no third-party import at all.
15
+ * Diagnostics carry absolute offsets and the editor adapter
16
+ * (src/components/notes/cm-lint.ts) translates them; an mdast tree, when one
17
+ * is wanted, is injected. That is what lets the same rules run in the CLI
18
+ * before an agent PUTs a file as run in the editor while a human types it.
19
+ *
20
+ * Five modules underneath the rules carry the mechanics, and all five exist so
21
+ * the same seven rules can be USED in more places rather than so there can be
22
+ * more of them:
23
+ *
24
+ * ./severity the four levels and what each one means. The ladder is by
25
+ * what the document LOSES, not by how loud a rule feels.
26
+ * ./textEdits a fix is a list of ranged replacements over one document
27
+ * state, applied reverse-sorted, all of it or none of it.
28
+ * ./fixAll every fix in a document composed into one atomic change,
29
+ * with an account of what was applied, refused and left.
30
+ * ./appliesTo the path-glob dialect a scope is written in, and the
31
+ * judgement that one of those globs is doubtful.
32
+ * ./config what a workspace may say about these rules — off, another
33
+ * severity, a path scope — and the linter that reads its own
34
+ * config back and flags the settings that silently cost it a
35
+ * rule.
36
+ * ./frontmatterSchema
37
+ * the one dial that adds a check: a JSON Schema a workspace
38
+ * declares for a kind of document, read against sfora's own
39
+ * frontmatter value model, off until it is written down — and
40
+ * linted itself, so a keyword sfora does not implement is
41
+ * reported rather than quietly enforcing nothing.
42
+ */
43
+ export * from "./types.js";
44
+ export * from "./severity.js";
45
+ export * from "./textEdits.js";
46
+ export * from "./appliesTo.js";
47
+ export * from "./config.js";
48
+ export * from "./frontmatterSchema.js";
49
+ export * from "./lintSource.js";
50
+ export * from "./fixAll.js";
51
+ export * from "./rules/index.js";
@@ -0,0 +1,56 @@
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, markdownStart, maskNonRenderingContexts, maskNonRenderingLines, type FenceRegion, } from "../lineGeometry.js";
4
+ /**
5
+ * Lines a prose rule must not look at: fences, the frontmatter block, and
6
+ * anything else the mask says never reaches a reader.
7
+ */
8
+ export declare function isSkippableLine(ctx: LintContext, i: number): boolean;
9
+ /** True when a `[` at `index` is escaped as `\[`. */
10
+ export declare function isEscaped(line: string, index: number): boolean;
11
+ export interface ScannedWikiToken extends WikiLink {
12
+ lineIndex: number;
13
+ /**
14
+ * Absolute span of the whole token — INCLUDING the `!` when the token is an
15
+ * embed, because that is what a fix has to replace. A "remove link" edit
16
+ * measured from the bracket leaves the marker behind, and a bare `!` in front
17
+ * of the label it just inserted is not what anybody asked for.
18
+ */
19
+ from: number;
20
+ to: number;
21
+ }
22
+ /**
23
+ * Every `[[…]]` and `![[…]]` token outside fences, frontmatter and inline code.
24
+ *
25
+ * The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
26
+ * needs a non-empty body and happily spans newlines. Lint needs the opposite
27
+ * on both counts — it has to see `[[]]` to complain about it, and a `[[` left
28
+ * open at the end of a line is someone mid-typing, not a link. So the span
29
+ * matcher is local and one line wide; the meaning of what is inside the
30
+ * brackets still comes from `parseWikiToken`, which owns the prefix table.
31
+ *
32
+ * The bang is the embed marker (card #298) and it is read with one wrinkle the
33
+ * rest of the reader does not have: an ESCAPED bang is not a marker. `\![[x]]`
34
+ * is a literal exclamation mark in front of an ordinary wiki link, so the
35
+ * escape test below is taken at the BRACKET — which is the character that
36
+ * decides whether there is a token here at all — while a second, separate test
37
+ * at the bang decides only whether that token is an embed.
38
+ */
39
+ export declare function scanWikiTokens(ctx: LintContext): ScannedWikiToken[];
40
+ export declare function buildLintContext(source: string, opts?: LintSourceOptions): LintContext;
41
+ /**
42
+ * Run the rules over a document. Diagnostics come back in document order; a
43
+ * rule that throws contributes none and does not take the others down with it.
44
+ */
45
+ export declare function lintSource(source: string, opts?: LintSourceOptions): SforaDiagnostic[];
46
+ /**
47
+ * Apply a fix to the source it was computed against.
48
+ *
49
+ * All of it or none of it. The edits are applied last-one-first so earlier
50
+ * offsets stay valid — the same reason the editor dispatches them as one
51
+ * transaction — and a fix whose edits do not compose (out of range, inverted,
52
+ * overlapping) returns the source untouched rather than landing the half of
53
+ * itself that happened to fit. See ./textEdits for the mechanics and
54
+ * `tryApplyEdits` for the version that says WHY it declined.
55
+ */
56
+ export declare function applyLintFix(source: string, fix: LintFix): string;
@@ -0,0 +1,188 @@
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, the non-rendering mask — and each rule reads it. The geometry itself
7
+ // belongs to ../lineGeometry, which every other scanner in the package reads
8
+ // too; lint owns none of it and re-exports all of it. Nothing here throws: a
9
+ // parser that blows up
10
+ // costs the AST, a rule that blows up costs that rule's diagnostics, and the
11
+ // caller still gets a list. Lint is a background nicety in a text editor; it
12
+ // is never allowed to be the reason a document will not open.
13
+ import { codeSpanMask, fenceMask, frontmatterExtent, lineText, markdownStart, maskNonRenderingLines, } from "../lineGeometry.js";
14
+ import { MAX_PARSE_INPUT_BYTES } from "../parseWithFallback.js";
15
+ import { parseWikiToken } from "../wikiLinks.js";
16
+ import { resolveLintRules, scopeAdmits } from "./config.js";
17
+ import { selectFrontmatterSchemas } from "./frontmatterSchema.js";
18
+ import { SFORA_LINT_RULES } from "./rules/index.js";
19
+ import { severityRank } from "./severity.js";
20
+ import { tryApplyEdits } from "./textEdits.js";
21
+ // The fence/frontmatter geometry moved to ../lineGeometry once a second
22
+ // caller (checklist.ts) needed it, and `codeSpanMask` followed it there once a
23
+ // third (blocks/parsers.ts) had hand-copied it. Re-exported so the rules — and
24
+ // everything importing through lint/index, the Convex shim included — keep
25
+ // their existing import path.
26
+ export { codeSpanMask, fenceMask, fencedRegions, frontmatterExtent, isNonRenderingRange, lineText, markdownStart, maskNonRenderingContexts, maskNonRenderingLines, } from "../lineGeometry.js";
27
+ /**
28
+ * Lines a prose rule must not look at: fences, the frontmatter block, and
29
+ * anything else the mask says never reaches a reader.
30
+ */
31
+ export function isSkippableLine(ctx, i) {
32
+ return ctx.inFence(i) || ctx.inFrontmatter(i) || ctx.nonRendering(i);
33
+ }
34
+ /** True when a `[` at `index` is escaped as `\[`. */
35
+ export function isEscaped(line, index) {
36
+ let slashes = 0;
37
+ for (let i = index - 1; i >= 0 && line[i] === "\\"; i--)
38
+ slashes++;
39
+ return slashes % 2 === 1;
40
+ }
41
+ /**
42
+ * Every `[[…]]` and `![[…]]` token outside fences, frontmatter and inline code.
43
+ *
44
+ * The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
45
+ * needs a non-empty body and happily spans newlines. Lint needs the opposite
46
+ * on both counts — it has to see `[[]]` to complain about it, and a `[[` left
47
+ * open at the end of a line is someone mid-typing, not a link. So the span
48
+ * matcher is local and one line wide; the meaning of what is inside the
49
+ * brackets still comes from `parseWikiToken`, which owns the prefix table.
50
+ *
51
+ * The bang is the embed marker (card #298) and it is read with one wrinkle the
52
+ * rest of the reader does not have: an ESCAPED bang is not a marker. `\![[x]]`
53
+ * is a literal exclamation mark in front of an ordinary wiki link, so the
54
+ * escape test below is taken at the BRACKET — which is the character that
55
+ * decides whether there is a token here at all — while a second, separate test
56
+ * at the bang decides only whether that token is an embed.
57
+ */
58
+ export function scanWikiTokens(ctx) {
59
+ const out = [];
60
+ for (let i = 0; i < ctx.lines.length; i++) {
61
+ if (isSkippableLine(ctx, i))
62
+ continue;
63
+ const line = lineText(ctx.lines, i);
64
+ const mask = codeSpanMask(line);
65
+ const re = /(!?)\[\[([^[\]\n]*)\]\]/g;
66
+ let m;
67
+ while ((m = re.exec(line)) !== null) {
68
+ const bracket = m.index + m[1].length;
69
+ if (mask[bracket] || isEscaped(line, bracket))
70
+ continue;
71
+ const embed = m[1] === "!" && !isEscaped(line, m.index);
72
+ const start = embed ? m.index : bracket;
73
+ out.push({
74
+ ...parseWikiToken(m[2], embed),
75
+ lineIndex: i,
76
+ from: ctx.lineStart(i) + start,
77
+ to: ctx.lineStart(i) + m.index + m[0].length,
78
+ });
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ export function buildLintContext(source, opts = {}) {
84
+ const lines = source.split("\n");
85
+ const starts = new Array(lines.length);
86
+ let offset = 0;
87
+ for (let i = 0; i < lines.length; i++) {
88
+ starts[i] = offset;
89
+ offset += lines[i].length + 1;
90
+ }
91
+ const frontmatter = frontmatterExtent(lines);
92
+ const fenceScanFrom = markdownStart(lines);
93
+ const fenced = fenceMask(lines, fenceScanFrom);
94
+ const masked = maskNonRenderingLines(lines, fenceScanFrom);
95
+ const fmEnd = frontmatter === null
96
+ ? -1
97
+ : frontmatter.close === -1
98
+ ? lines.length - 1
99
+ : frontmatter.close;
100
+ return {
101
+ source,
102
+ lines,
103
+ lineStart: (i) => starts[i] ?? source.length,
104
+ inFence: (i) => fenced[i] === true,
105
+ // A line that was blank to begin with is not non-rendering, it is empty,
106
+ // and calling it non-rendering would skip every blank line in the file.
107
+ nonRendering: (i) => masked[i] !== undefined &&
108
+ lineText(masked, i).trim() === "" &&
109
+ lineText(lines, i).trim() !== "",
110
+ inFrontmatter: (i) => frontmatter !== null && i >= 0 && i <= fmEnd,
111
+ frontmatter,
112
+ frontmatterSchemas: selectFrontmatterSchemas(opts.config?.frontmatterSchemas, opts.path),
113
+ ast: parseAstSafely(source, opts.parseAst),
114
+ resolveLink: opts.resolveLink,
115
+ };
116
+ }
117
+ // A throwing parser costs the AST and nothing else. The size cap is the same
118
+ // one parseWithFallback uses: past it we are looking at a paste accident, and
119
+ // no lint mark is worth parsing four megabytes on a keystroke.
120
+ //
121
+ // A leading BOM costs it too. `parseMarkdownAst` strips one before parsing, so
122
+ // every offset in the tree it returns is one short of the source the rules
123
+ // hold — and a rule that mixes the two paints its squiggle one character off.
124
+ // A document that opens with a BOM is rare enough, and the AST optional
125
+ // enough, that declining is the honest answer.
126
+ function parseAstSafely(source, parseAst) {
127
+ if (!parseAst || source.length > MAX_PARSE_INPUT_BYTES)
128
+ return undefined;
129
+ if (source.charCodeAt(0) === 0xfeff)
130
+ return undefined;
131
+ try {
132
+ return parseAst(source);
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ /**
139
+ * Run the rules over a document. Diagnostics come back in document order; a
140
+ * rule that throws contributes none and does not take the others down with it.
141
+ */
142
+ export function lintSource(source, opts = {}) {
143
+ const ctx = buildLintContext(source, opts);
144
+ const resolved = resolveLintRules(opts.rules ?? SFORA_LINT_RULES, opts.config);
145
+ const out = [];
146
+ for (const entry of resolved) {
147
+ if (entry.rule.needsAst && !ctx.ast)
148
+ continue;
149
+ if (!scopeAdmits(entry, opts.path))
150
+ continue;
151
+ try {
152
+ const produced = entry.rule.run(ctx);
153
+ // A severity override is applied HERE rather than inside the rule: a
154
+ // rule states what kind of loss it found, and the workspace states how
155
+ // loudly it wants to hear about that kind. Neither gets to be the other.
156
+ out.push(...(entry.severity === undefined
157
+ ? produced
158
+ : produced.map((d) => ({ ...d, severity: entry.severity }))));
159
+ }
160
+ catch {
161
+ // A broken rule is a missing mark, never a broken editor.
162
+ }
163
+ }
164
+ // Document order first, and severity only to break a tie at the same span —
165
+ // a gutter reads top to bottom, and re-ordering by severity would move a
166
+ // mark away from the line it is about.
167
+ return out.sort((a, b) => a.from !== b.from
168
+ ? a.from - b.from
169
+ : a.to !== b.to
170
+ ? a.to - b.to
171
+ : severityRank(a.severity) !== severityRank(b.severity)
172
+ ? severityRank(a.severity) - severityRank(b.severity)
173
+ : a.ruleId.localeCompare(b.ruleId));
174
+ }
175
+ /**
176
+ * Apply a fix to the source it was computed against.
177
+ *
178
+ * All of it or none of it. The edits are applied last-one-first so earlier
179
+ * offsets stay valid — the same reason the editor dispatches them as one
180
+ * transaction — and a fix whose edits do not compose (out of range, inverted,
181
+ * overlapping) returns the source untouched rather than landing the half of
182
+ * itself that happened to fit. See ./textEdits for the mechanics and
183
+ * `tryApplyEdits` for the version that says WHY it declined.
184
+ */
185
+ export function applyLintFix(source, fix) {
186
+ const result = tryApplyEdits(source, fix.edits);
187
+ return result.ok ? result.text : source;
188
+ }
@@ -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,2 @@
1
+ import type { LintRule } from "../types.js";
2
+ export declare const frontmatterSchemaRule: LintRule;
@@ -0,0 +1,92 @@
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 frontmatter a workspace SAID this kind of document would carry.
4
+ //
5
+ // The sibling of `malformed-frontmatter`, and the seam that rule's docblock
6
+ // named: "What the VALUES mean (a `status:` that is not a real status, a
7
+ // missing required key) is schema validation, and it belongs to the frontmatter
8
+ // schema, not here. The registry is the seam where that rule lands when it
9
+ // exists." This is it (card #299).
10
+ //
11
+ // The split holds in both directions. That rule asks whether sfora's own parser
12
+ // will READ a line; this one asks whether what it read is what the workspace
13
+ // declared. A line with no colon is dropped by `parseYaml`, so this rule never
14
+ // sees the key and never says a word about it — the other rule already did, and
15
+ // two marks on one line for one mistake is how a gutter stops being read.
16
+ //
17
+ // OFF UNTIL DECLARED. With no `frontmatterSchemas` in the lint config this rule
18
+ // costs one property lookup and returns nothing, which is the state every
19
+ // workspace is in until it writes one down.
20
+ import { frontmatterKeyLines, readFrontmatter, validateFrontmatter, } from "../frontmatterSchema.js";
21
+ import { lineText } from "../lintSource.js";
22
+ const RULE_ID = "sfora/frontmatter-schema";
23
+ export const frontmatterSchemaRule = {
24
+ id: RULE_ID,
25
+ run(ctx) {
26
+ const schemas = ctx.frontmatterSchemas;
27
+ if (!schemas || schemas.length === 0)
28
+ return [];
29
+ const block = ctx.frontmatter;
30
+ // An UNCLOSED block is `malformed-frontmatter`'s error and nobody else's.
31
+ // Reading fields out of a block whose end nobody knows would report a
32
+ // missing `status` on a document whose entire body is being read as
33
+ // frontmatter — the exact misdirection open-knowledge documents as a sharp
34
+ // edge in its own stack (a YAML error yielding zero diagnostics and then a
35
+ // missing-property complaint). Declining is the honest answer.
36
+ if (block && block.close === -1)
37
+ return [];
38
+ // No block at all is still a document to grade: a `decision` that forgot
39
+ // its frontmatter entirely is missing every required key, and saying so at
40
+ // the top of the file is more use than silence. The values are empty, so
41
+ // only `required` can fire.
42
+ const data = block ? readFrontmatter(ctx.lines, block) : {};
43
+ const keyLines = block
44
+ ? frontmatterKeyLines(ctx.lines, block)
45
+ : new Map();
46
+ const openStart = ctx.lineStart(0);
47
+ const openEnd = openStart + lineText(ctx.lines, 0).length;
48
+ const out = [];
49
+ for (const violation of validateFrontmatter(data, schemas)) {
50
+ const line = keyLines.get(violation.key);
51
+ const from = line === undefined ? openStart : ctx.lineStart(line);
52
+ const to = line === undefined ? openEnd : from + lineText(ctx.lines, line).length;
53
+ out.push({
54
+ from,
55
+ to,
56
+ severity: violation.severity,
57
+ ruleId: RULE_ID,
58
+ message: violation.message,
59
+ // A fix only where there is exactly one thing to do: a value the
60
+ // schema all but named — a `const`, a one-member `enum`, or an enum
61
+ // entry the author spelled with the wrong case — replaced in place.
62
+ //
63
+ // What is deliberately absent is the other half somebody will ask for:
64
+ // INSERTING a missing required key. Where a key goes inside a metadata
65
+ // block is the author's ordering, and inventing a value for a field the
66
+ // document does not have is a validator writing the document. Worse for
67
+ // a document with no block at all, where the fix would have to insert
68
+ // the `---` fences and so reinterpret every byte under them. The mark
69
+ // stands; the author writes the line.
70
+ ...(violation.kind === "invalid" &&
71
+ line !== undefined &&
72
+ violation.suggestion !== undefined
73
+ ? {
74
+ fixes: [
75
+ {
76
+ label: `Set \`${violation.key}\` to \`${violation.suggestion}\``,
77
+ edits: [
78
+ {
79
+ from,
80
+ to,
81
+ insert: `${violation.key}: ${violation.suggestion}`,
82
+ },
83
+ ],
84
+ },
85
+ ],
86
+ }
87
+ : {}),
88
+ });
89
+ }
90
+ return out;
91
+ },
92
+ };
@@ -0,0 +1,11 @@
1
+ import { brokenWikiLinkRule } from "./broken-wiki-link.js";
2
+ import { frontmatterSchemaRule } from "./frontmatter-schema.js";
3
+ import { malformedCalloutRule } from "./malformed-callout.js";
4
+ import { malformedChecklistRule } from "./malformed-checklist.js";
5
+ import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
6
+ import { malformedStructuredBlockRule } from "./malformed-structured-block.js";
7
+ import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
8
+ import { orphanReferenceRule } from "./orphan-reference.js";
9
+ import type { LintRule } from "../types.js";
10
+ export declare const SFORA_LINT_RULES: readonly LintRule[];
11
+ export { brokenWikiLinkRule, frontmatterSchemaRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
@@ -0,0 +1,32 @@
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 { frontmatterSchemaRule } from "./frontmatter-schema.js";
12
+ import { malformedCalloutRule } from "./malformed-callout.js";
13
+ import { malformedChecklistRule } from "./malformed-checklist.js";
14
+ import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
15
+ import { malformedStructuredBlockRule } from "./malformed-structured-block.js";
16
+ import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
17
+ import { orphanReferenceRule } from "./orphan-reference.js";
18
+ export const SFORA_LINT_RULES = [
19
+ brokenWikiLinkRule,
20
+ malformedWikiLinkRule,
21
+ malformedCalloutRule,
22
+ malformedStructuredBlockRule,
23
+ orphanReferenceRule,
24
+ malformedChecklistRule,
25
+ malformedFrontmatterRule,
26
+ // After the structural frontmatter rule on purpose. A block whose SHAPE is
27
+ // wrong and a block whose VALUES are wrong are different complaints, and the
28
+ // gutter should read the shape first: a line the parser dropped is not a
29
+ // missing required key, even though it looks exactly like one.
30
+ frontmatterSchemaRule,
31
+ ];
32
+ export { brokenWikiLinkRule, frontmatterSchemaRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
@@ -0,0 +1,2 @@
1
+ import type { LintRule } from "../types.js";
2
+ export declare const malformedCalloutRule: LintRule;
@@ -0,0 +1,88 @@
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
+ // `> [!WARN]` — a callout marker whose type does not exist.
4
+ //
5
+ // This one is worth a mark precisely because nothing visibly breaks: an
6
+ // unrecognized marker falls back to an ordinary blockquote, so the author sees
7
+ // quoted prose and assumes the callout "did not save". The mark says what
8
+ // actually happened.
9
+ //
10
+ // Line-based on purpose. Callouts are a line grammar (see callout.ts), and
11
+ // reading them off the mdast tree would only re-derive the same first line of
12
+ // the same blockquote — with a chance of disagreeing with the reader, which is
13
+ // the one thing a linter must never do.
14
+ import { CALLOUT_TYPES, resolveCalloutType } from "../../callout.js";
15
+ import { isSkippableLine, lineText } from "../lintSource.js";
16
+ const RULE_ID = "sfora/malformed-callout";
17
+ const QUOTED_MARKER = /^ {0,3}>[ \t]?[ \t]*(\[!([A-Za-z]+)\]([+-])?)/;
18
+ const QUOTE_LINE = /^ {0,3}>/;
19
+ /**
20
+ * What people write when they mean one of ours and the grammar does NOT already
21
+ * accept it. The real alias table lives in `callout.ts` — `warn`, `error`,
22
+ * `summary` and the rest resolve to a type and never reach this rule — so what
23
+ * is left here is near-misses: plurals, and words that name a tone we spell
24
+ * differently.
25
+ *
26
+ * A word maps to a single type or to nothing: `alert` reads as both "warning"
27
+ * and "caution" depending on who is writing, and a fix that guesses wrong
28
+ * silently changes the document's tone. Ambiguous words still get the
29
+ * diagnostic — just no one-click answer.
30
+ */
31
+ const NEAR_MISSES = {
32
+ warnings: ["warning"],
33
+ notes: ["note"],
34
+ tips: ["tip"],
35
+ urgent: ["important"],
36
+ alert: ["warning", "caution"],
37
+ reminder: ["note", "todo"],
38
+ };
39
+ export const malformedCalloutRule = {
40
+ id: RULE_ID,
41
+ run(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 match = QUOTED_MARKER.exec(line);
48
+ if (!match)
49
+ continue;
50
+ // A marker only counts on the FIRST line of a blockquote; anywhere else
51
+ // it is prose that happens to contain brackets.
52
+ if (i > 0 && QUOTE_LINE.test(lineText(ctx.lines, i - 1)))
53
+ continue;
54
+ const keyword = match[2];
55
+ if (resolveCalloutType(keyword))
56
+ continue;
57
+ // The fold marker is grammar, not part of the type, so a one-click fix
58
+ // rewrites the word and puts the `+`/`-` back — otherwise accepting the
59
+ // fix would quietly un-collapse the callout.
60
+ const fold = match[3] ?? "";
61
+ const markerStart = ctx.lineStart(i) + line.indexOf(match[1]);
62
+ const candidates = NEAR_MISSES[keyword.toLowerCase()] ?? [];
63
+ const fixes = candidates.length === 1
64
+ ? [
65
+ {
66
+ label: `Change to [!${candidates[0].toUpperCase()}]`,
67
+ edits: [
68
+ {
69
+ from: markerStart,
70
+ to: markerStart + match[1].length,
71
+ insert: `[!${candidates[0].toUpperCase()}]${fold}`,
72
+ },
73
+ ],
74
+ },
75
+ ]
76
+ : [];
77
+ out.push({
78
+ from: markerStart,
79
+ to: markerStart + match[1].length,
80
+ severity: "warning",
81
+ ruleId: RULE_ID,
82
+ message: `\`${keyword}\` isn't a callout type, so this renders as a plain quote. Try ${CALLOUT_TYPES.join(", ")}.`,
83
+ ...(fixes.length > 0 ? { fixes } : {}),
84
+ });
85
+ }
86
+ return out;
87
+ },
88
+ };
@@ -0,0 +1,2 @@
1
+ import type { LintRule } from "../types.js";
2
+ export declare const malformedChecklistRule: LintRule;