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,92 @@
|
|
|
1
|
+
export type SuspiciousGlobReason =
|
|
2
|
+
/** `docs/` — a document path never ends in a separator, so this matches nothing. */
|
|
3
|
+
"trailing-slash"
|
|
4
|
+
/** `/docs/**` — sfora fs paths are relative; nothing starts with a separator. */
|
|
5
|
+
| "leading-slash"
|
|
6
|
+
/** `docs\**` — a Windows separator in a path made of forward slashes. */
|
|
7
|
+
| "backslash"
|
|
8
|
+
/** `docs**` — `**` only spans segments between separators; here it means `*`. */
|
|
9
|
+
| "undelimited-globstar"
|
|
10
|
+
/** `docs` — no glob character and no extension, so it can only name a folder. */
|
|
11
|
+
| "folder-not-document";
|
|
12
|
+
export interface InvalidGlob {
|
|
13
|
+
pattern: string;
|
|
14
|
+
detail: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SuspiciousGlob {
|
|
17
|
+
pattern: string;
|
|
18
|
+
reason: SuspiciousGlobReason;
|
|
19
|
+
/** What the author probably meant, when there is one obvious answer. */
|
|
20
|
+
suggestion?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface CompiledAppliesTo {
|
|
23
|
+
/** Patterns that are not globs at all. They match nothing and are reported. */
|
|
24
|
+
invalid: InvalidGlob[];
|
|
25
|
+
/** Patterns that compiled but almost certainly do not mean what they say. */
|
|
26
|
+
suspicious: SuspiciousGlob[];
|
|
27
|
+
/** True when no positive pattern was authored — the scope is "everything". */
|
|
28
|
+
matchesEverything: boolean;
|
|
29
|
+
/** Does `path` fall in scope? `undefined` and `""` never do. */
|
|
30
|
+
matches(path: string | undefined): boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Translate one glob body to a regular expression.
|
|
34
|
+
*
|
|
35
|
+
* Supported: `*` (within a segment), `**` (across segments, only when it is a
|
|
36
|
+
* whole segment), `?` (one non-separator character), `[abc]` / `[!abc]`
|
|
37
|
+
* character classes, and one level of `{a,b}` alternation. Everything else is
|
|
38
|
+
* matched literally. Throws on a pattern that cannot be read — an unbalanced
|
|
39
|
+
* bracket or brace — because a half-read glob would match the wrong documents
|
|
40
|
+
* silently, and the caller turns the throw into a reported diagnostic.
|
|
41
|
+
*/
|
|
42
|
+
export declare function globToRegExp(body: string): RegExp;
|
|
43
|
+
/** Everything doubtful about one already-compilable pattern body. */
|
|
44
|
+
export declare function suspicionsOf(body: string): SuspiciousGlob[];
|
|
45
|
+
/**
|
|
46
|
+
* Compile a rule's scope.
|
|
47
|
+
*
|
|
48
|
+
* A leading `!` negates: the pattern excludes rather than includes. With no
|
|
49
|
+
* positive pattern at all the scope is everything, so a config that lists only
|
|
50
|
+
* exclusions still means "every document except these" rather than "none".
|
|
51
|
+
*/
|
|
52
|
+
export declare function compileAppliesTo(appliesTo: string | readonly string[] | undefined): CompiledAppliesTo;
|
|
53
|
+
/**
|
|
54
|
+
* INCLUDES that match none of the documents the workspace actually has.
|
|
55
|
+
*
|
|
56
|
+
* The strongest signal there is, and the only one that needs the world. A
|
|
57
|
+
* pattern can be perfectly formed and perfectly plausible and still be a typo
|
|
58
|
+
* — `doc` where the workspace spells the folder `docs` — and nothing but the
|
|
59
|
+
* corpus says so. Callers with no corpus to hand simply do not ask.
|
|
60
|
+
*
|
|
61
|
+
* Negations are NOT judged here, and that is the whole subtlety. An include
|
|
62
|
+
* that matches nothing turns the rule off; an exclusion that matches nothing
|
|
63
|
+
* turns nothing off — `["docs/**", "!docs/archive/**"]` on a workspace with no
|
|
64
|
+
* archive yet is not a mistake, it is a workspace that has not archived
|
|
65
|
+
* anything. Corpus emptiness is the author's caution, not their typo. What CAN
|
|
66
|
+
* be wrong about a negation is structural, and {@link findUnreachableNegations}
|
|
67
|
+
* is where that is asked.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findZeroMatchPatterns(appliesTo: string | readonly string[] | undefined, knownPaths: readonly string[]): string[];
|
|
70
|
+
/**
|
|
71
|
+
* Exclusions that could never subtract anything from this scope's includes.
|
|
72
|
+
*
|
|
73
|
+
* The question a negation answers to is not "does the workspace hold a file
|
|
74
|
+
* here today" but "could a document this scope includes ever land here". A
|
|
75
|
+
* scope of `["docs/**", "!notes/**"]` excludes a tree it never included: the
|
|
76
|
+
* two patterns cannot meet on any path at all, so the `!` is doing nothing and
|
|
77
|
+
* almost certainly names the wrong folder. That verdict needs no corpus, which
|
|
78
|
+
* is why it is reported whether or not one was given.
|
|
79
|
+
*
|
|
80
|
+
* With no include at all the scope is "everything", so every well-formed
|
|
81
|
+
* negation can reach something and none are reported.
|
|
82
|
+
*/
|
|
83
|
+
export declare function findUnreachableNegations(appliesTo: string | readonly string[] | undefined): string[];
|
|
84
|
+
/**
|
|
85
|
+
* Could any one path be matched by both globs?
|
|
86
|
+
*
|
|
87
|
+
* Deliberately one-sided: it answers `false` only when the two are provably
|
|
88
|
+
* disjoint, and `true` for anything it cannot decide. Two globs meeting is the
|
|
89
|
+
* normal case, and a diagnostic that fires on a scope the author got right is
|
|
90
|
+
* worse than one that misses a scope they got wrong.
|
|
91
|
+
*/
|
|
92
|
+
export declare function globsCanIntersect(a: string, b: string): boolean;
|
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
// Path globs for scoping a rule, and the judgement that a glob is suspicious.
|
|
4
|
+
//
|
|
5
|
+
// A workspace narrows a rule with `appliesTo: ["projects/*/docs/**"]`. That is
|
|
6
|
+
// a small feature with a large failure mode: a glob that matches nothing is
|
|
7
|
+
// indistinguishable, from the outside, from a rule that found nothing to say.
|
|
8
|
+
// The workspace turns a rule off by accident and the linter's silence looks
|
|
9
|
+
// like a clean bill of health. So the compiler reports what it could not
|
|
10
|
+
// compile AND what it compiled but doubts, and `lintLintConfig` turns both
|
|
11
|
+
// into diagnostics — the linter linting its own config.
|
|
12
|
+
//
|
|
13
|
+
// The matcher is hand-rolled rather than picomatch. This module is copied
|
|
14
|
+
// verbatim into the published CLI by `packages/sfora/scripts/sync-format.mjs`,
|
|
15
|
+
// and the lint core's standing contract is that it carries no third-party
|
|
16
|
+
// import; a glob dialect this small is not worth breaking that for. What it
|
|
17
|
+
// supports is stated below and tested; anything outside it is a literal.
|
|
18
|
+
//
|
|
19
|
+
// The SUSPICION rules are sfora's, not a port. Open-knowledge flags a doc
|
|
20
|
+
// extension in a pattern because their document names are extensionless —
|
|
21
|
+
// every sfora fs path ends in `.md` (see `markdown/slug.ts:91`), so for us the
|
|
22
|
+
// extension is normal and its ABSENCE, on a pattern with no glob character at
|
|
23
|
+
// all, is the tell: `docs` names a folder that no document is ever called.
|
|
24
|
+
/** Extensions a sfora document path ends in. `markdown/slug.ts` is the source. */
|
|
25
|
+
const DOC_EXTENSIONS = [".md"];
|
|
26
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
27
|
+
/** `a/b/../c` and `./a` normalized the one way the fs surface writes them. */
|
|
28
|
+
function normalizePath(path) {
|
|
29
|
+
let out = path.replace(/\\/g, "/");
|
|
30
|
+
while (out.startsWith("./"))
|
|
31
|
+
out = out.slice(2);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Translate one glob body to a regular expression.
|
|
36
|
+
*
|
|
37
|
+
* Supported: `*` (within a segment), `**` (across segments, only when it is a
|
|
38
|
+
* whole segment), `?` (one non-separator character), `[abc]` / `[!abc]`
|
|
39
|
+
* character classes, and one level of `{a,b}` alternation. Everything else is
|
|
40
|
+
* matched literally. Throws on a pattern that cannot be read — an unbalanced
|
|
41
|
+
* bracket or brace — because a half-read glob would match the wrong documents
|
|
42
|
+
* silently, and the caller turns the throw into a reported diagnostic.
|
|
43
|
+
*/
|
|
44
|
+
export function globToRegExp(body) {
|
|
45
|
+
let out = "";
|
|
46
|
+
let braceDepth = 0;
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < body.length) {
|
|
49
|
+
const c = body[i];
|
|
50
|
+
if (c === "*") {
|
|
51
|
+
const doubled = body[i + 1] === "*";
|
|
52
|
+
if (doubled) {
|
|
53
|
+
// `**/` eats whole segments including none at all; a trailing `**`
|
|
54
|
+
// eats the rest of the path. `a**b` is not a globstar — the caller
|
|
55
|
+
// flags it and we read it as a single `*`.
|
|
56
|
+
const atSegmentStart = i === 0 || body[i - 1] === "/";
|
|
57
|
+
const followedBySlash = body[i + 2] === "/";
|
|
58
|
+
const atEnd = i + 2 === body.length;
|
|
59
|
+
if (atSegmentStart && followedBySlash) {
|
|
60
|
+
out += "(?:[^/]*\\/)*";
|
|
61
|
+
i += 3;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (atSegmentStart && atEnd) {
|
|
65
|
+
out += ".*";
|
|
66
|
+
i += 2;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
out += "[^/]*";
|
|
70
|
+
i += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
out += "[^/]*";
|
|
74
|
+
i += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (c === "?") {
|
|
78
|
+
out += "[^/]";
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (c === "[") {
|
|
83
|
+
const close = body.indexOf("]", i + 1);
|
|
84
|
+
if (close === -1)
|
|
85
|
+
throw new Error("unclosed `[`");
|
|
86
|
+
let body_ = body.slice(i + 1, close);
|
|
87
|
+
if (body_ === "")
|
|
88
|
+
throw new Error("empty character class");
|
|
89
|
+
const negated = body_.startsWith("!") || body_.startsWith("^");
|
|
90
|
+
if (negated)
|
|
91
|
+
body_ = body_.slice(1);
|
|
92
|
+
if (body_ === "")
|
|
93
|
+
throw new Error("empty character class");
|
|
94
|
+
out += `[${negated ? "^" : ""}${body_.replace(/\\/g, "\\\\")}]`;
|
|
95
|
+
i = close + 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === "{") {
|
|
99
|
+
if (braceDepth > 0)
|
|
100
|
+
throw new Error("nested `{`");
|
|
101
|
+
braceDepth += 1;
|
|
102
|
+
out += "(?:";
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === "}") {
|
|
107
|
+
if (braceDepth === 0)
|
|
108
|
+
throw new Error("unmatched `}`");
|
|
109
|
+
braceDepth -= 1;
|
|
110
|
+
out += ")";
|
|
111
|
+
i += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === "," && braceDepth > 0) {
|
|
115
|
+
out += "|";
|
|
116
|
+
i += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
out += c.replace(/[.+^$()|\\]/g, "\\$&");
|
|
120
|
+
i += 1;
|
|
121
|
+
}
|
|
122
|
+
if (braceDepth > 0)
|
|
123
|
+
throw new Error("unclosed `{`");
|
|
124
|
+
return new RegExp(`^${out}$`);
|
|
125
|
+
}
|
|
126
|
+
/** Everything doubtful about one already-compilable pattern body. */
|
|
127
|
+
export function suspicionsOf(body) {
|
|
128
|
+
const out = [];
|
|
129
|
+
if (body.includes("\\")) {
|
|
130
|
+
out.push({
|
|
131
|
+
pattern: body,
|
|
132
|
+
reason: "backslash",
|
|
133
|
+
suggestion: body.replace(/\\/g, "/"),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (body.endsWith("/")) {
|
|
137
|
+
out.push({
|
|
138
|
+
pattern: body,
|
|
139
|
+
reason: "trailing-slash",
|
|
140
|
+
suggestion: `${body}**`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (body.startsWith("/")) {
|
|
144
|
+
out.push({
|
|
145
|
+
pattern: body,
|
|
146
|
+
reason: "leading-slash",
|
|
147
|
+
suggestion: body.slice(1),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
151
|
+
if (body[i] !== "*" || body[i + 1] !== "*")
|
|
152
|
+
continue;
|
|
153
|
+
const atSegmentStart = i === 0 || body[i - 1] === "/";
|
|
154
|
+
const atSegmentEnd = i + 2 === body.length || body[i + 2] === "/";
|
|
155
|
+
if (atSegmentStart && atSegmentEnd) {
|
|
156
|
+
i += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
out.push({ pattern: body, reason: "undelimited-globstar" });
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
if (!GLOB_CHARS.test(body) && !body.includes("\\")) {
|
|
163
|
+
const lower = body.toLowerCase();
|
|
164
|
+
const hasExtension = DOC_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
165
|
+
if (!hasExtension && !body.endsWith("/") && !body.startsWith("/")) {
|
|
166
|
+
out.push({
|
|
167
|
+
pattern: body,
|
|
168
|
+
reason: "folder-not-document",
|
|
169
|
+
suggestion: `${body}/**`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function authoredPatterns(appliesTo) {
|
|
176
|
+
const raw = appliesTo === undefined
|
|
177
|
+
? []
|
|
178
|
+
: typeof appliesTo === "string"
|
|
179
|
+
? [appliesTo]
|
|
180
|
+
: [...appliesTo];
|
|
181
|
+
return raw.map((pattern) => pattern.trim()).filter((p) => p.length > 0);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Compile a rule's scope.
|
|
185
|
+
*
|
|
186
|
+
* A leading `!` negates: the pattern excludes rather than includes. With no
|
|
187
|
+
* positive pattern at all the scope is everything, so a config that lists only
|
|
188
|
+
* exclusions still means "every document except these" rather than "none".
|
|
189
|
+
*/
|
|
190
|
+
export function compileAppliesTo(appliesTo) {
|
|
191
|
+
const invalid = [];
|
|
192
|
+
const suspicious = [];
|
|
193
|
+
const positive = [];
|
|
194
|
+
const negative = [];
|
|
195
|
+
let positiveCount = 0;
|
|
196
|
+
for (const pattern of authoredPatterns(appliesTo)) {
|
|
197
|
+
const negated = pattern.startsWith("!");
|
|
198
|
+
const body = negated ? pattern.slice(1) : pattern;
|
|
199
|
+
if (!negated)
|
|
200
|
+
positiveCount += 1;
|
|
201
|
+
if (body.length === 0) {
|
|
202
|
+
invalid.push({ pattern, detail: "empty pattern" });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
let re;
|
|
206
|
+
try {
|
|
207
|
+
re = globToRegExp(body);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
invalid.push({
|
|
211
|
+
pattern,
|
|
212
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
(negated ? negative : positive).push(re);
|
|
217
|
+
for (const doubt of suspicionsOf(body)) {
|
|
218
|
+
// `suspicionsOf` reads a BODY and so its repair is a body too. Handing
|
|
219
|
+
// `notes/**` back for `!notes/` would invert the author's exclusion into
|
|
220
|
+
// an inclusion — the one edit that changes which documents the rule runs
|
|
221
|
+
// on, offered as a typo fix. The `!` goes back on.
|
|
222
|
+
suspicious.push({
|
|
223
|
+
...doubt,
|
|
224
|
+
pattern,
|
|
225
|
+
...(negated && doubt.suggestion !== undefined
|
|
226
|
+
? { suggestion: `!${doubt.suggestion}` }
|
|
227
|
+
: {}),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const matchesEverything = positiveCount === 0;
|
|
232
|
+
return {
|
|
233
|
+
invalid,
|
|
234
|
+
suspicious,
|
|
235
|
+
matchesEverything,
|
|
236
|
+
matches(path) {
|
|
237
|
+
if (path === undefined || path === "")
|
|
238
|
+
return false;
|
|
239
|
+
const normalized = normalizePath(path);
|
|
240
|
+
if (normalized === "")
|
|
241
|
+
return false;
|
|
242
|
+
const included = matchesEverything || positive.some((re) => re.test(normalized));
|
|
243
|
+
if (!included)
|
|
244
|
+
return false;
|
|
245
|
+
return !negative.some((re) => re.test(normalized));
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* INCLUDES that match none of the documents the workspace actually has.
|
|
251
|
+
*
|
|
252
|
+
* The strongest signal there is, and the only one that needs the world. A
|
|
253
|
+
* pattern can be perfectly formed and perfectly plausible and still be a typo
|
|
254
|
+
* — `doc` where the workspace spells the folder `docs` — and nothing but the
|
|
255
|
+
* corpus says so. Callers with no corpus to hand simply do not ask.
|
|
256
|
+
*
|
|
257
|
+
* Negations are NOT judged here, and that is the whole subtlety. An include
|
|
258
|
+
* that matches nothing turns the rule off; an exclusion that matches nothing
|
|
259
|
+
* turns nothing off — `["docs/**", "!docs/archive/**"]` on a workspace with no
|
|
260
|
+
* archive yet is not a mistake, it is a workspace that has not archived
|
|
261
|
+
* anything. Corpus emptiness is the author's caution, not their typo. What CAN
|
|
262
|
+
* be wrong about a negation is structural, and {@link findUnreachableNegations}
|
|
263
|
+
* is where that is asked.
|
|
264
|
+
*/
|
|
265
|
+
export function findZeroMatchPatterns(appliesTo, knownPaths) {
|
|
266
|
+
if (knownPaths.length === 0)
|
|
267
|
+
return [];
|
|
268
|
+
const normalized = knownPaths
|
|
269
|
+
.map(normalizePath)
|
|
270
|
+
.filter((path) => path !== "");
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const pattern of authoredPatterns(appliesTo)) {
|
|
273
|
+
if (pattern.startsWith("!"))
|
|
274
|
+
continue;
|
|
275
|
+
if (pattern.length === 0)
|
|
276
|
+
continue;
|
|
277
|
+
let re;
|
|
278
|
+
try {
|
|
279
|
+
re = globToRegExp(pattern);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Already reported as invalid; not reported twice as empty.
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!normalized.some((path) => re.test(path)))
|
|
286
|
+
out.push(pattern);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Exclusions that could never subtract anything from this scope's includes.
|
|
292
|
+
*
|
|
293
|
+
* The question a negation answers to is not "does the workspace hold a file
|
|
294
|
+
* here today" but "could a document this scope includes ever land here". A
|
|
295
|
+
* scope of `["docs/**", "!notes/**"]` excludes a tree it never included: the
|
|
296
|
+
* two patterns cannot meet on any path at all, so the `!` is doing nothing and
|
|
297
|
+
* almost certainly names the wrong folder. That verdict needs no corpus, which
|
|
298
|
+
* is why it is reported whether or not one was given.
|
|
299
|
+
*
|
|
300
|
+
* With no include at all the scope is "everything", so every well-formed
|
|
301
|
+
* negation can reach something and none are reported.
|
|
302
|
+
*/
|
|
303
|
+
export function findUnreachableNegations(appliesTo) {
|
|
304
|
+
const patterns = authoredPatterns(appliesTo);
|
|
305
|
+
const includes = patterns.filter((p) => !p.startsWith("!"));
|
|
306
|
+
if (includes.length === 0)
|
|
307
|
+
return [];
|
|
308
|
+
const out = [];
|
|
309
|
+
for (const pattern of patterns) {
|
|
310
|
+
if (!pattern.startsWith("!"))
|
|
311
|
+
continue;
|
|
312
|
+
const body = pattern.slice(1);
|
|
313
|
+
if (body.length === 0)
|
|
314
|
+
continue;
|
|
315
|
+
if (!includes.some((include) => globsCanIntersect(body, include))) {
|
|
316
|
+
out.push(pattern);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Could any one path be matched by both globs?
|
|
323
|
+
*
|
|
324
|
+
* Deliberately one-sided: it answers `false` only when the two are provably
|
|
325
|
+
* disjoint, and `true` for anything it cannot decide. Two globs meeting is the
|
|
326
|
+
* normal case, and a diagnostic that fires on a scope the author got right is
|
|
327
|
+
* worse than one that misses a scope they got wrong.
|
|
328
|
+
*/
|
|
329
|
+
export function globsCanIntersect(a, b) {
|
|
330
|
+
return segmentsIntersect(normalizePath(a).split("/"), normalizePath(b).split("/"));
|
|
331
|
+
}
|
|
332
|
+
function segmentsIntersect(a, b) {
|
|
333
|
+
if (a.length === 0 && b.length === 0)
|
|
334
|
+
return true;
|
|
335
|
+
// A `**` is the only segment that can stand for no segment at all, so it is
|
|
336
|
+
// the only thing a run-out list can still meet.
|
|
337
|
+
if (a.length === 0)
|
|
338
|
+
return b.every((seg) => seg === "**");
|
|
339
|
+
if (b.length === 0)
|
|
340
|
+
return a.every((seg) => seg === "**");
|
|
341
|
+
if (a[0] === "**") {
|
|
342
|
+
return (segmentsIntersect(a.slice(1), b) || segmentsIntersect(a, b.slice(1)));
|
|
343
|
+
}
|
|
344
|
+
if (b[0] === "**") {
|
|
345
|
+
return (segmentsIntersect(a, b.slice(1)) || segmentsIntersect(a.slice(1), b));
|
|
346
|
+
}
|
|
347
|
+
if (!segmentTokensIntersect(a[0], b[0]))
|
|
348
|
+
return false;
|
|
349
|
+
return segmentsIntersect(a.slice(1), b.slice(1));
|
|
350
|
+
}
|
|
351
|
+
/** One segment against one segment, same one-sided honesty as above. */
|
|
352
|
+
function segmentTokensIntersect(a, b) {
|
|
353
|
+
const globA = GLOB_CHARS.test(a);
|
|
354
|
+
const globB = GLOB_CHARS.test(b);
|
|
355
|
+
if (!globA && !globB)
|
|
356
|
+
return a === b;
|
|
357
|
+
try {
|
|
358
|
+
if (!globA)
|
|
359
|
+
return globToRegExp(b).test(a);
|
|
360
|
+
if (!globB)
|
|
361
|
+
return globToRegExp(a).test(b);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
// Two patterns. Deciding whether `*.md` and `plan.*` overlap is a language
|
|
367
|
+
// intersection, and we do not need the answer badly enough to build one.
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type CompiledAppliesTo } from "./appliesTo.js";
|
|
2
|
+
import { type FrontmatterSchemaDeclaration } from "./frontmatterSchema.js";
|
|
3
|
+
import { type LintSeverity } from "./severity.js";
|
|
4
|
+
import type { LintRule } from "./types.js";
|
|
5
|
+
/** A severity, or `off` to silence the rule entirely. */
|
|
6
|
+
export type LintSeveritySetting = LintSeverity | "off";
|
|
7
|
+
export interface LintRuleSettings {
|
|
8
|
+
/** Report this rule's diagnostics at another level, or not at all. */
|
|
9
|
+
severity?: LintSeveritySetting;
|
|
10
|
+
/**
|
|
11
|
+
* Path globs the rule is limited to. A leading `!` excludes. Absent means
|
|
12
|
+
* every document.
|
|
13
|
+
*/
|
|
14
|
+
appliesTo?: string | readonly string[];
|
|
15
|
+
}
|
|
16
|
+
export interface LintConfig {
|
|
17
|
+
/** Keyed by rule id — `sfora/broken-wiki-link`, not `broken-wiki-link`. */
|
|
18
|
+
rules?: Readonly<Record<string, LintRuleSettings>>;
|
|
19
|
+
/**
|
|
20
|
+
* The frontmatter schemas this workspace holds its documents to — the third
|
|
21
|
+
* key the docblock above predicted, arrived at on card #299.
|
|
22
|
+
*
|
|
23
|
+
* It is the one dial that ADDS a check rather than moving or silencing one,
|
|
24
|
+
* and it does not contradict the decision above, because a schema is not a
|
|
25
|
+
* style layer: it is the workspace stating its own law for a kind of
|
|
26
|
+
* document, which is the only kind of rule sfora-law has ever carried. What
|
|
27
|
+
* makes it safe is that it is empty by default and that it cannot invent a
|
|
28
|
+
* rule id — the check runs under `sfora/frontmatter-schema`, which the
|
|
29
|
+
* registry declares and the two dials above can turn off and re-level like
|
|
30
|
+
* any other.
|
|
31
|
+
*
|
|
32
|
+
* The declarations get linted too, by `lintFrontmatterSchemas` in
|
|
33
|
+
* ./frontmatterSchema, for exactly the reason this file lints its own config.
|
|
34
|
+
*/
|
|
35
|
+
frontmatterSchemas?: readonly FrontmatterSchemaDeclaration[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A complaint about the CONFIG rather than about a document.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately not a `SforaDiagnostic`: there are no byte offsets here,
|
|
41
|
+
* because the config is an object and may never have been text. `at` is a JSON
|
|
42
|
+
* path into it — `["rules", "sfora/broken-wiki-link", "appliesTo", 0]` — which
|
|
43
|
+
* a settings UI can turn into a field and an agent can turn into a sentence.
|
|
44
|
+
* A shape that faked `from`/`to` would be lying to every consumer that draws a
|
|
45
|
+
* squiggle at them.
|
|
46
|
+
*/
|
|
47
|
+
export interface LintConfigDiagnostic {
|
|
48
|
+
severity: LintSeverity;
|
|
49
|
+
/** Namespaced like a rule id, and stable: `sfora/config-suspicious-glob`. */
|
|
50
|
+
ruleId: string;
|
|
51
|
+
message: string;
|
|
52
|
+
at: (string | number)[];
|
|
53
|
+
}
|
|
54
|
+
export declare const CONFIG_RULE_IDS: {
|
|
55
|
+
readonly unknownRule: "sfora/config-unknown-rule";
|
|
56
|
+
readonly invalidSeverity: "sfora/config-invalid-severity";
|
|
57
|
+
readonly invalidGlob: "sfora/config-invalid-glob";
|
|
58
|
+
readonly suspiciousGlob: "sfora/config-suspicious-glob";
|
|
59
|
+
readonly emptyScope: "sfora/config-glob-matches-nothing";
|
|
60
|
+
readonly deadNegation: "sfora/config-negation-excludes-nothing";
|
|
61
|
+
};
|
|
62
|
+
export interface LintConfigOptions {
|
|
63
|
+
/** The registry the config's rule ids are checked against. */
|
|
64
|
+
rules?: readonly LintRule[];
|
|
65
|
+
/**
|
|
66
|
+
* Document paths the workspace actually holds. When given, a pattern that
|
|
67
|
+
* matches none of them is reported. Without it that check is skipped rather
|
|
68
|
+
* than guessed at.
|
|
69
|
+
*/
|
|
70
|
+
knownPaths?: readonly string[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read the config back and report everything about it that will silently cost
|
|
74
|
+
* the workspace a rule.
|
|
75
|
+
*
|
|
76
|
+
* Ordered by config path so two runs over the same object agree, and so a UI
|
|
77
|
+
* can group the marks by field without sorting them itself: the `rules` map
|
|
78
|
+
* first, sorted by rule id, then `frontmatterSchemas` in declaration order.
|
|
79
|
+
*/
|
|
80
|
+
export declare function lintLintConfig(config: LintConfig | undefined, opts?: LintConfigOptions): LintConfigDiagnostic[];
|
|
81
|
+
/** A rule paired with the settings that apply to it, scope already compiled. */
|
|
82
|
+
export interface ResolvedLintRule {
|
|
83
|
+
rule: LintRule;
|
|
84
|
+
/** What its diagnostics report at, after any override. */
|
|
85
|
+
severity?: LintSeverity;
|
|
86
|
+
scope?: CompiledAppliesTo;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Apply the config to the registry.
|
|
90
|
+
*
|
|
91
|
+
* A rule set to `off` is dropped here rather than filtered later, so a
|
|
92
|
+
* disabled rule costs no work at all. A rule with a scope keeps it: whether it
|
|
93
|
+
* runs depends on the document's path, which the runner knows and this
|
|
94
|
+
* function does not.
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveLintRules(rules: readonly LintRule[], config: LintConfig | undefined): ResolvedLintRule[];
|
|
97
|
+
/**
|
|
98
|
+
* Does a scoped rule run on this document?
|
|
99
|
+
*
|
|
100
|
+
* Fail-OPEN when the caller gave no path. A scope narrows a rule; a document
|
|
101
|
+
* whose path we do not know cannot be shown to fall outside one, and a linter
|
|
102
|
+
* of laws would rather post a mark the workspace scoped away than go quiet on
|
|
103
|
+
* a document it simply could not place. Every caller that HAS a path passes
|
|
104
|
+
* it, and the editor is the only one that sometimes does not.
|
|
105
|
+
*/
|
|
106
|
+
export declare function scopeAdmits(resolved: ResolvedLintRule, path: string | undefined): boolean;
|