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.
- package/README.md +147 -6
- package/dist/SforaFs.js +278 -10
- package/dist/api-client.d.ts +290 -5
- package/dist/api-client.js +307 -22
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +323 -29
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +81 -0
- package/dist/format/blocks/dropClosure.js +196 -0
- package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
- package/dist/format/blocks/markdown-block-catalog.js +162 -0
- package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
- package/dist/format/blocks/markdown-block-ids.mjs +25 -0
- package/dist/format/blocks/parsers.d.ts +105 -0
- package/dist/format/blocks/parsers.js +442 -0
- package/dist/format/blocks/structured-block-schema.d.ts +8 -0
- package/dist/format/blocks/structured-block-schema.js +30 -0
- package/dist/format/callout.d.ts +128 -0
- package/dist/format/callout.js +227 -0
- package/dist/format/cardMarkdown.d.ts +2 -0
- package/dist/format/cardMarkdown.js +10 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +158 -0
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +19 -4
- package/dist/format/index.js +28 -4
- package/dist/format/lineGeometry.d.ts +100 -0
- package/dist/format/lineGeometry.js +424 -0
- 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 +49 -0
- package/dist/format/lint/index.js +51 -0
- package/dist/format/lint/lintSource.d.ts +56 -0
- package/dist/format/lint/lintSource.js +188 -0
- package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/broken-wiki-link.js +45 -0
- 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 +11 -0
- package/dist/format/lint/rules/index.js +32 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +88 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +65 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
- package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
- package/dist/format/lint/rules/malformed-structured-block.js +134 -0
- package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
- package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
- package/dist/format/lint/rules/orphan-reference.js +87 -0
- 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 +116 -0
- package/dist/format/lint/types.js +16 -0
- package/dist/format/markdown/dates.js +2 -0
- package/dist/format/markdown/document.js +2 -0
- package/dist/format/markdown/index.js +2 -0
- package/dist/format/markdown/mentions.js +2 -0
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +65 -0
- package/dist/format/markdown/yaml.js +2 -0
- package/dist/format/noteMarkdown.js +2 -0
- package/dist/format/parseWithFallback.d.ts +13 -0
- package/dist/format/parseWithFallback.js +98 -0
- package/dist/format/plaintext.d.ts +5 -0
- package/dist/format/plaintext.js +51 -0
- package/dist/format/postMarkdown.js +3 -1
- 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/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +78 -0
- package/dist/format/wikiLinks.js +266 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/mcp-server.js +5 -2
- 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 +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,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,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,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
|
+
};
|