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.
- package/README.md +139 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +243 -4
- package/dist/api-client.js +248 -20
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +317 -26
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +132 -0
- package/dist/render.js +208 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- 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
|
|
13
|
-
* offsets and the editor adapter
|
|
14
|
-
* them; an mdast tree, when one
|
|
15
|
-
*
|
|
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
|
|
15
|
-
* offsets and the editor adapter
|
|
16
|
-
* them; an mdast tree, when one
|
|
17
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
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 =
|
|
65
|
+
const re = /(!?)\[\[([^[\]\n]*)\]\]/g;
|
|
49
66
|
let m;
|
|
50
67
|
while ((m = re.exec(line)) !== null) {
|
|
51
|
-
|
|
68
|
+
const bracket = m.index + m[1].length;
|
|
69
|
+
if (mask[bracket] || isEscaped(line, bracket))
|
|
52
70
|
continue;
|
|
53
|
-
const
|
|
71
|
+
const embed = m[1] === "!" && !isEscaped(line, m.index);
|
|
72
|
+
const start = embed ? m.index : bracket;
|
|
54
73
|
out.push({
|
|
55
|
-
...parseWikiToken(m[
|
|
74
|
+
...parseWikiToken(m[2], embed),
|
|
56
75
|
lineIndex: i,
|
|
57
|
-
from,
|
|
58
|
-
to:
|
|
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 =
|
|
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
|
|
144
|
+
const resolved = resolveLintRules(opts.rules ?? SFORA_LINT_RULES, opts.config);
|
|
123
145
|
const out = [];
|
|
124
|
-
for (const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
|
147
|
-
|
|
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,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,
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
26
|
-
warn: ["warning"],
|
|
31
|
+
const NEAR_MISSES = {
|
|
27
32
|
warnings: ["warning"],
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 (
|
|
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 =
|
|
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.
|
|
8
|
-
//
|
|
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: "
|
|
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
|
-
|
|
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;
|