sfora-cli 0.10.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 (68) hide show
  1. package/README.md +139 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +243 -4
  4. package/dist/api-client.js +248 -20
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +317 -26
  8. package/dist/format/blockSplice.d.ts +135 -0
  9. package/dist/format/blockSplice.js +330 -0
  10. package/dist/format/blocks/dropClosure.d.ts +10 -1
  11. package/dist/format/blocks/dropClosure.js +11 -1
  12. package/dist/format/callout.d.ts +69 -7
  13. package/dist/format/callout.js +112 -15
  14. package/dist/format/checklist.js +11 -4
  15. package/dist/format/formatAxes.d.ts +228 -0
  16. package/dist/format/formatAxes.js +454 -0
  17. package/dist/format/index.d.ts +1 -0
  18. package/dist/format/index.js +4 -0
  19. package/dist/format/lineGeometry.d.ts +34 -4
  20. package/dist/format/lineGeometry.js +140 -40
  21. package/dist/format/lint/appliesTo.d.ts +92 -0
  22. package/dist/format/lint/appliesTo.js +369 -0
  23. package/dist/format/lint/config.d.ts +106 -0
  24. package/dist/format/lint/config.js +205 -0
  25. package/dist/format/lint/fixAll.d.ts +62 -0
  26. package/dist/format/lint/fixAll.js +107 -0
  27. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  28. package/dist/format/lint/frontmatterSchema.js +660 -0
  29. package/dist/format/lint/index.d.ts +34 -5
  30. package/dist/format/lint/index.js +34 -5
  31. package/dist/format/lint/lintSource.d.ts +27 -7
  32. package/dist/format/lint/lintSource.js +67 -33
  33. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  34. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  35. package/dist/format/lint/rules/index.d.ts +2 -1
  36. package/dist/format/lint/rules/index.js +7 -1
  37. package/dist/format/lint/rules/malformed-callout.js +25 -16
  38. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  39. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  40. package/dist/format/lint/severity.d.ts +15 -0
  41. package/dist/format/lint/severity.js +50 -0
  42. package/dist/format/lint/textEdits.d.ts +86 -0
  43. package/dist/format/lint/textEdits.js +162 -0
  44. package/dist/format/lint/types.d.ts +44 -8
  45. package/dist/format/markdown/slug.d.ts +28 -0
  46. package/dist/format/markdown/slug.js +63 -0
  47. package/dist/format/plaintext.js +13 -3
  48. package/dist/format/sheetCellSpans.d.ts +95 -0
  49. package/dist/format/sheetCellSpans.js +223 -0
  50. package/dist/format/sheetSelection.d.ts +136 -0
  51. package/dist/format/sheetSelection.js +282 -0
  52. package/dist/format/textStats.d.ts +23 -0
  53. package/dist/format/textStats.js +80 -0
  54. package/dist/format/wikiLinks.d.ts +60 -1
  55. package/dist/format/wikiLinks.js +195 -9
  56. package/dist/index.d.ts +26 -1
  57. package/dist/index.js +20 -3
  58. package/dist/opener.d.ts +23 -0
  59. package/dist/opener.js +26 -0
  60. package/dist/render.d.ts +132 -0
  61. package/dist/render.js +208 -0
  62. package/dist/shell-commands.d.ts +34 -0
  63. package/dist/shell-commands.js +108 -0
  64. package/dist/watch.d.ts +79 -0
  65. package/dist/watch.js +113 -0
  66. package/dist/web-url.d.ts +39 -0
  67. package/dist/web-url.js +63 -0
  68. package/package.json +1 -1
@@ -9,12 +9,41 @@
9
9
  * only allowed to point at places where sfora will read it differently than
10
10
  * they will.
11
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.
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.
17
40
  */
18
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";
19
47
  export * from "./lintSource.js";
48
+ export * from "./fixAll.js";
20
49
  export * from "./rules/index.js";
@@ -11,12 +11,41 @@
11
11
  * only allowed to point at places where sfora will read it differently than
12
12
  * they will.
13
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.
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.
19
42
  */
20
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";
21
49
  export * from "./lintSource.js";
50
+ export * from "./fixAll.js";
22
51
  export * from "./rules/index.js";
@@ -1,18 +1,26 @@
1
1
  import { type WikiLink } from "../wikiLinks.js";
2
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. */
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
+ */
5
8
  export declare function isSkippableLine(ctx: LintContext, i: number): boolean;
6
9
  /** True when a `[` at `index` is escaped as `\[`. */
7
10
  export declare function isEscaped(line: string, index: number): boolean;
8
11
  export interface ScannedWikiToken extends WikiLink {
9
12
  lineIndex: number;
10
- /** Absolute span of the whole `[[…]]` token. */
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
+ */
11
19
  from: number;
12
20
  to: number;
13
21
  }
14
22
  /**
15
- * Every `[[…]]` token outside fences, frontmatter and inline code.
23
+ * Every `[[…]]` and `![[…]]` token outside fences, frontmatter and inline code.
16
24
  *
17
25
  * The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
18
26
  * needs a non-empty body and happily spans newlines. Lint needs the opposite
@@ -20,6 +28,13 @@ export interface ScannedWikiToken extends WikiLink {
20
28
  * open at the end of a line is someone mid-typing, not a link. So the span
21
29
  * matcher is local and one line wide; the meaning of what is inside the
22
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.
23
38
  */
24
39
  export declare function scanWikiTokens(ctx: LintContext): ScannedWikiToken[];
25
40
  export declare function buildLintContext(source: string, opts?: LintSourceOptions): LintContext;
@@ -29,8 +44,13 @@ export declare function buildLintContext(source: string, opts?: LintSourceOption
29
44
  */
30
45
  export declare function lintSource(source: string, opts?: LintSourceOptions): SforaDiagnostic[];
31
46
  /**
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.
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.
35
55
  */
36
56
  export declare function applyLintFix(source: string, fix: LintFix): string;
@@ -3,23 +3,33 @@
3
3
  // The sfora-law runner, plus the document geometry every rule shares.
4
4
  //
5
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
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
7
10
  // costs the AST, a rule that blows up costs that rule's diagnostics, and the
8
11
  // caller still gets a list. Lint is a background nicety in a text editor; it
9
12
  // is never allowed to be the reason a document will not open.
10
- import { codeSpanMask, fenceMask, frontmatterExtent, lineText, } from "../lineGeometry.js";
13
+ import { codeSpanMask, fenceMask, frontmatterExtent, lineText, markdownStart, maskNonRenderingLines, } from "../lineGeometry.js";
11
14
  import { MAX_PARSE_INPUT_BYTES } from "../parseWithFallback.js";
12
15
  import { parseWikiToken } from "../wikiLinks.js";
16
+ import { resolveLintRules, scopeAdmits } from "./config.js";
17
+ import { selectFrontmatterSchemas } from "./frontmatterSchema.js";
13
18
  import { SFORA_LINT_RULES } from "./rules/index.js";
19
+ import { severityRank } from "./severity.js";
20
+ import { tryApplyEdits } from "./textEdits.js";
14
21
  // The fence/frontmatter geometry moved to ../lineGeometry once a second
15
22
  // caller (checklist.ts) needed it, and `codeSpanMask` followed it there once a
16
23
  // third (blocks/parsers.ts) had hand-copied it. Re-exported so the rules — and
17
24
  // everything importing through lint/index, the Convex shim included — keep
18
25
  // 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. */
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
+ */
21
31
  export function isSkippableLine(ctx, i) {
22
- return ctx.inFence(i) || ctx.inFrontmatter(i);
32
+ return ctx.inFence(i) || ctx.inFrontmatter(i) || ctx.nonRendering(i);
23
33
  }
24
34
  /** True when a `[` at `index` is escaped as `\[`. */
25
35
  export function isEscaped(line, index) {
@@ -29,7 +39,7 @@ export function isEscaped(line, index) {
29
39
  return slashes % 2 === 1;
30
40
  }
31
41
  /**
32
- * Every `[[…]]` token outside fences, frontmatter and inline code.
42
+ * Every `[[…]]` and `![[…]]` token outside fences, frontmatter and inline code.
33
43
  *
34
44
  * The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
35
45
  * needs a non-empty body and happily spans newlines. Lint needs the opposite
@@ -37,6 +47,13 @@ export function isEscaped(line, index) {
37
47
  * open at the end of a line is someone mid-typing, not a link. So the span
38
48
  * matcher is local and one line wide; the meaning of what is inside the
39
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.
40
57
  */
41
58
  export function scanWikiTokens(ctx) {
42
59
  const out = [];
@@ -45,17 +62,19 @@ export function scanWikiTokens(ctx) {
45
62
  continue;
46
63
  const line = lineText(ctx.lines, i);
47
64
  const mask = codeSpanMask(line);
48
- const re = /\[\[([^[\]\n]*)\]\]/g;
65
+ const re = /(!?)\[\[([^[\]\n]*)\]\]/g;
49
66
  let m;
50
67
  while ((m = re.exec(line)) !== null) {
51
- if (mask[m.index] || isEscaped(line, m.index))
68
+ const bracket = m.index + m[1].length;
69
+ if (mask[bracket] || isEscaped(line, bracket))
52
70
  continue;
53
- const from = ctx.lineStart(i) + m.index;
71
+ const embed = m[1] === "!" && !isEscaped(line, m.index);
72
+ const start = embed ? m.index : bracket;
54
73
  out.push({
55
- ...parseWikiToken(m[1]),
74
+ ...parseWikiToken(m[2], embed),
56
75
  lineIndex: i,
57
- from,
58
- to: from + m[0].length,
76
+ from: ctx.lineStart(i) + start,
77
+ to: ctx.lineStart(i) + m.index + m[0].length,
59
78
  });
60
79
  }
61
80
  }
@@ -70,12 +89,9 @@ export function buildLintContext(source, opts = {}) {
70
89
  offset += lines[i].length + 1;
71
90
  }
72
91
  const frontmatter = frontmatterExtent(lines);
73
- const fenceScanFrom = frontmatter === null
74
- ? 0
75
- : frontmatter.close === -1
76
- ? lines.length
77
- : frontmatter.close + 1;
92
+ const fenceScanFrom = markdownStart(lines);
78
93
  const fenced = fenceMask(lines, fenceScanFrom);
94
+ const masked = maskNonRenderingLines(lines, fenceScanFrom);
79
95
  const fmEnd = frontmatter === null
80
96
  ? -1
81
97
  : frontmatter.close === -1
@@ -86,8 +102,14 @@ export function buildLintContext(source, opts = {}) {
86
102
  lines,
87
103
  lineStart: (i) => starts[i] ?? source.length,
88
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() !== "",
89
110
  inFrontmatter: (i) => frontmatter !== null && i >= 0 && i <= fmEnd,
90
111
  frontmatter,
112
+ frontmatterSchemas: selectFrontmatterSchemas(opts.config?.frontmatterSchemas, opts.path),
91
113
  ast: parseAstSafely(source, opts.parseAst),
92
114
  resolveLink: opts.resolveLink,
93
115
  };
@@ -119,36 +141,48 @@ function parseAstSafely(source, parseAst) {
119
141
  */
120
142
  export function lintSource(source, opts = {}) {
121
143
  const ctx = buildLintContext(source, opts);
122
- const rules = opts.rules ?? SFORA_LINT_RULES;
144
+ const resolved = resolveLintRules(opts.rules ?? SFORA_LINT_RULES, opts.config);
123
145
  const out = [];
124
- for (const rule of rules) {
125
- if (rule.needsAst && !ctx.ast)
146
+ for (const entry of resolved) {
147
+ if (entry.rule.needsAst && !ctx.ast)
148
+ continue;
149
+ if (!scopeAdmits(entry, opts.path))
126
150
  continue;
127
151
  try {
128
- out.push(...rule.run(ctx));
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 }))));
129
159
  }
130
160
  catch {
131
161
  // A broken rule is a missing mark, never a broken editor.
132
162
  }
133
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.
134
167
  return out.sort((a, b) => a.from !== b.from
135
168
  ? a.from - b.from
136
169
  : a.to !== b.to
137
170
  ? a.to - b.to
138
- : a.ruleId.localeCompare(b.ruleId));
171
+ : severityRank(a.severity) !== severityRank(b.severity)
172
+ ? severityRank(a.severity) - severityRank(b.severity)
173
+ : a.ruleId.localeCompare(b.ruleId));
139
174
  }
140
175
  /**
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.
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.
144
184
  */
145
185
  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;
186
+ const result = tryApplyEdits(source, fix.edits);
187
+ return result.ok ? result.text : source;
154
188
  }
@@ -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
+ };
@@ -1,4 +1,5 @@
1
1
  import { brokenWikiLinkRule } from "./broken-wiki-link.js";
2
+ import { frontmatterSchemaRule } from "./frontmatter-schema.js";
2
3
  import { malformedCalloutRule } from "./malformed-callout.js";
3
4
  import { malformedChecklistRule } from "./malformed-checklist.js";
4
5
  import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
@@ -7,4 +8,4 @@ import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
7
8
  import { orphanReferenceRule } from "./orphan-reference.js";
8
9
  import type { LintRule } from "../types.js";
9
10
  export declare const SFORA_LINT_RULES: readonly LintRule[];
10
- export { brokenWikiLinkRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
11
+ export { brokenWikiLinkRule, frontmatterSchemaRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // Adding a rule is adding a file and a line here. Nothing else knows the list.
10
10
  import { brokenWikiLinkRule } from "./broken-wiki-link.js";
11
+ import { frontmatterSchemaRule } from "./frontmatter-schema.js";
11
12
  import { malformedCalloutRule } from "./malformed-callout.js";
12
13
  import { malformedChecklistRule } from "./malformed-checklist.js";
13
14
  import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
@@ -22,5 +23,10 @@ export const SFORA_LINT_RULES = [
22
23
  orphanReferenceRule,
23
24
  malformedChecklistRule,
24
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,
25
31
  ];
26
- export { brokenWikiLinkRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
32
+ export { brokenWikiLinkRule, frontmatterSchemaRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
@@ -11,25 +11,30 @@
11
11
  // reading them off the mdast tree would only re-derive the same first line of
12
12
  // the same blockquote — with a chance of disagreeing with the reader, which is
13
13
  // the one thing a linter must never do.
14
- import { CALLOUT_TYPES, isCalloutType } from "../../callout.js";
14
+ import { CALLOUT_TYPES, resolveCalloutType } from "../../callout.js";
15
15
  import { isSkippableLine, lineText } from "../lintSource.js";
16
16
  const RULE_ID = "sfora/malformed-callout";
17
- const QUOTED_MARKER = /^ {0,3}>[ \t]?[ \t]*(\[!([A-Za-z]+)\])/;
17
+ const QUOTED_MARKER = /^ {0,3}>[ \t]?[ \t]*(\[!([A-Za-z]+)\]([+-])?)/;
18
18
  const QUOTE_LINE = /^ {0,3}>/;
19
19
  /**
20
- * What people write when they mean one of ours. A word maps to a single type
21
- * or to nothing: `info` reads as both "note" and "tip" depending on who is
22
- * writing, and a fix that guesses wrong silently changes the document's tone.
23
- * Ambiguous words still get the diagnostic — just no one-click answer.
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.
24
30
  */
25
- const ALIASES = {
26
- warn: ["warning"],
31
+ const NEAR_MISSES = {
27
32
  warnings: ["warning"],
28
- error: ["caution"],
29
- danger: ["caution"],
30
- attention: ["important"],
31
- info: ["note", "tip"],
32
- hint: ["note", "tip"],
33
+ notes: ["note"],
34
+ tips: ["tip"],
35
+ urgent: ["important"],
36
+ alert: ["warning", "caution"],
37
+ reminder: ["note", "todo"],
33
38
  };
34
39
  export const malformedCalloutRule = {
35
40
  id: RULE_ID,
@@ -47,10 +52,14 @@ export const malformedCalloutRule = {
47
52
  if (i > 0 && QUOTE_LINE.test(lineText(ctx.lines, i - 1)))
48
53
  continue;
49
54
  const keyword = match[2];
50
- if (isCalloutType(keyword))
55
+ if (resolveCalloutType(keyword))
51
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] ?? "";
52
61
  const markerStart = ctx.lineStart(i) + line.indexOf(match[1]);
53
- const candidates = ALIASES[keyword.toLowerCase()] ?? [];
62
+ const candidates = NEAR_MISSES[keyword.toLowerCase()] ?? [];
54
63
  const fixes = candidates.length === 1
55
64
  ? [
56
65
  {
@@ -59,7 +68,7 @@ export const malformedCalloutRule = {
59
68
  {
60
69
  from: markerStart,
61
70
  to: markerStart + match[1].length,
62
- insert: `[!${candidates[0].toUpperCase()}]`,
71
+ insert: `[!${candidates[0].toUpperCase()}]${fold}`,
63
72
  },
64
73
  ],
65
74
  },
@@ -4,8 +4,13 @@
4
4
  //
5
5
  // `- [] ship it`, `- [ x] ship it`, `- [x]ship it` — all three look like tasks
6
6
  // to a human and count as none to `checklistProgress`, so the card says 0/3
7
- // and nobody can see why. Info, not warning: the line still reads fine, it
8
- // just is not counted.
7
+ // and nobody can see why.
8
+ //
9
+ // A HINT, the quietest of the four levels, and the only rule that sits there.
10
+ // It is the level for "the render is exactly right and a derived number is
11
+ // not": nothing on the page is missing or wrong — the line reads as the author
12
+ // wrote it — and the only casualty is a progress count computed somewhere
13
+ // else. Everything louder is reserved for bytes that do not reach the reader.
9
14
  //
10
15
  // Deliberately narrow. Only bullet lines, only brackets holding nothing but a
11
16
  // space or an `x`, and only when there is text after them — `- []` on its own
@@ -44,7 +49,7 @@ export const malformedChecklistRule = {
44
49
  out.push({
45
50
  from,
46
51
  to: from + line.length,
47
- severity: "info",
52
+ severity: "hint",
48
53
  ruleId: RULE_ID,
49
54
  message: "This isn't a task checkbox, so it won't be counted. Tasks are exactly `- [ ] ` or `- [x] `.",
50
55
  fixes: [
@@ -27,7 +27,12 @@ export const malformedFrontmatterRule = {
27
27
  {
28
28
  from: openStart,
29
29
  to: openStart + lineText(ctx.lines, 0).length,
30
- severity: "warning",
30
+ // The one error in sfora-law. Every other rule costs the author a
31
+ // sentence, a link or a count; this one costs the document its
32
+ // entire identity — no title, no status, no id, and a body that
33
+ // starts with three dashes. Nothing downstream of the mark means
34
+ // what it says.
35
+ severity: "error",
31
36
  ruleId: RULE_ID,
32
37
  // No fix: where the block was meant to end is the author's
33
38
  // knowledge, and guessing turns their first paragraph into metadata.
@@ -0,0 +1,15 @@
1
+ /** Most severe first. The order IS the rank. */
2
+ export declare const LINT_SEVERITIES: readonly ["error", "warning", "info", "hint"];
3
+ /**
4
+ * Nothing in sfora-law is an alarm. Even `error` only says "sfora will read
5
+ * this differently than you will" — the bytes on disk are always the user's to
6
+ * keep, and no rule here ever refuses a document.
7
+ */
8
+ export type LintSeverity = (typeof LINT_SEVERITIES)[number];
9
+ /** 0 for `error`, 3 for `hint`. Lower is more severe. */
10
+ export declare function severityRank(severity: LintSeverity): number;
11
+ /** Negative when `a` is more severe than `b`. Sorts most-severe-first. */
12
+ export declare function compareSeverity(a: LintSeverity, b: LintSeverity): number;
13
+ /** True when `severity` is at least as severe as `floor`. */
14
+ export declare function atLeastAsSevere(severity: LintSeverity, floor: LintSeverity): boolean;
15
+ export declare function isLintSeverity(value: unknown): value is LintSeverity;