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
|
@@ -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;
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
// What a workspace is allowed to say about sfora-law, and the linter that
|
|
4
|
+
// reads it back.
|
|
5
|
+
//
|
|
6
|
+
// Two dials, deliberately: a rule can be turned OFF or moved to another
|
|
7
|
+
// SEVERITY, and it can be SCOPED to a set of paths. Nothing else. There is no
|
|
8
|
+
// dial that adds a rule, because there is no generic style layer behind these
|
|
9
|
+
// seven to add (board card #225, decided: sfora lints its own laws, not user
|
|
10
|
+
// style). What this file is for is the other half of that decision — the door
|
|
11
|
+
// stays open. A workspace-level opt-in, if one is ever wanted, is a third key
|
|
12
|
+
// in `LintConfig` and a second registry passed to `lintSource`; every consumer
|
|
13
|
+
// downstream of here already reads severities it did not choose and scopes it
|
|
14
|
+
// did not write. Nothing about that future costs anything today.
|
|
15
|
+
//
|
|
16
|
+
// The config is DATA, not a file format. This module never reads a file and
|
|
17
|
+
// never parses YAML — where the object comes from (frontmatter, a Convex row,
|
|
18
|
+
// a CLI flag) is the caller's business, and keeping it that way is what lets
|
|
19
|
+
// the same resolution run in the editor, the CLI and a Convex mutation.
|
|
20
|
+
//
|
|
21
|
+
// And the config gets linted. A rule id with a typo in it, a severity that is
|
|
22
|
+
// not one of the four, a glob that matches nothing — each of those turns a
|
|
23
|
+
// rule off silently, which is the worst thing a linter can do, because the
|
|
24
|
+
// symptom is a clean document. `lintLintConfig` is what makes that loud.
|
|
25
|
+
import { compileAppliesTo, findUnreachableNegations, findZeroMatchPatterns, } from "./appliesTo.js";
|
|
26
|
+
import { lintFrontmatterSchemas, } from "./frontmatterSchema.js";
|
|
27
|
+
import { isLintSeverity, LINT_SEVERITIES } from "./severity.js";
|
|
28
|
+
export const CONFIG_RULE_IDS = {
|
|
29
|
+
unknownRule: "sfora/config-unknown-rule",
|
|
30
|
+
invalidSeverity: "sfora/config-invalid-severity",
|
|
31
|
+
invalidGlob: "sfora/config-invalid-glob",
|
|
32
|
+
suspiciousGlob: "sfora/config-suspicious-glob",
|
|
33
|
+
emptyScope: "sfora/config-glob-matches-nothing",
|
|
34
|
+
deadNegation: "sfora/config-negation-excludes-nothing",
|
|
35
|
+
};
|
|
36
|
+
const SUSPICION_MESSAGE = {
|
|
37
|
+
"trailing-slash": "ends in `/`, and no document path does — this pattern matches nothing.",
|
|
38
|
+
"leading-slash": "starts with `/`, and sfora paths are relative — this pattern matches nothing.",
|
|
39
|
+
backslash: "uses `\\` as a separator; sfora paths are written with `/`.",
|
|
40
|
+
"undelimited-globstar": "has a `**` that is not a whole path segment, so it behaves as a plain `*`.",
|
|
41
|
+
"folder-not-document": "names a folder, and every sfora document path ends in a file extension — this pattern matches nothing.",
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Read the config back and report everything about it that will silently cost
|
|
45
|
+
* the workspace a rule.
|
|
46
|
+
*
|
|
47
|
+
* Ordered by config path so two runs over the same object agree, and so a UI
|
|
48
|
+
* can group the marks by field without sorting them itself: the `rules` map
|
|
49
|
+
* first, sorted by rule id, then `frontmatterSchemas` in declaration order.
|
|
50
|
+
*/
|
|
51
|
+
export function lintLintConfig(config, opts = {}) {
|
|
52
|
+
const rules = opts.rules;
|
|
53
|
+
const known = rules ? new Set(rules.map((rule) => rule.id)) : null;
|
|
54
|
+
const out = [];
|
|
55
|
+
const entries = Object.entries(config?.rules ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
56
|
+
for (const [ruleId, settings] of entries) {
|
|
57
|
+
const at = ["rules", ruleId];
|
|
58
|
+
if (known && !known.has(ruleId)) {
|
|
59
|
+
out.push({
|
|
60
|
+
severity: "warning",
|
|
61
|
+
ruleId: CONFIG_RULE_IDS.unknownRule,
|
|
62
|
+
// Naming the near miss is the whole value of the mark: the typo is
|
|
63
|
+
// almost always a namespace that has been dropped or doubled.
|
|
64
|
+
message: `\`${ruleId}\` is not a sfora-law rule, so this setting does nothing.${nearest(ruleId, known)}`,
|
|
65
|
+
at,
|
|
66
|
+
});
|
|
67
|
+
// Still check the globs below: the id may be fixed later and the scope
|
|
68
|
+
// kept, and reporting one problem at a time is a slow way to fix two.
|
|
69
|
+
}
|
|
70
|
+
const severity = settings?.severity;
|
|
71
|
+
if (severity !== undefined && severity !== "off" && !isLintSeverity(severity)) {
|
|
72
|
+
out.push({
|
|
73
|
+
severity: "warning",
|
|
74
|
+
ruleId: CONFIG_RULE_IDS.invalidSeverity,
|
|
75
|
+
message: `\`${String(severity)}\` is not a severity. Use ${LINT_SEVERITIES.join(", ")} or \`off\`.`,
|
|
76
|
+
at: [...at, "severity"],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const appliesTo = settings?.appliesTo;
|
|
80
|
+
if (appliesTo === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const compiled = compileAppliesTo(appliesTo);
|
|
83
|
+
const patterns = patternList(appliesTo);
|
|
84
|
+
for (const bad of compiled.invalid) {
|
|
85
|
+
out.push({
|
|
86
|
+
severity: "warning",
|
|
87
|
+
ruleId: CONFIG_RULE_IDS.invalidGlob,
|
|
88
|
+
message: `\`${bad.pattern}\` is not a usable path pattern (${bad.detail}), so it matches nothing.`,
|
|
89
|
+
at: [...at, "appliesTo", ...indexOf(patterns, bad.pattern)],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
for (const doubt of compiled.suspicious) {
|
|
93
|
+
const detail = SUSPICION_MESSAGE[doubt.reason] ?? "does not look like a path pattern.";
|
|
94
|
+
const hint = doubt.suggestion ? ` Did you mean \`${doubt.suggestion}\`?` : "";
|
|
95
|
+
out.push({
|
|
96
|
+
severity: "warning",
|
|
97
|
+
ruleId: CONFIG_RULE_IDS.suspiciousGlob,
|
|
98
|
+
message: `\`${doubt.pattern}\` ${detail}${hint}`,
|
|
99
|
+
at: [...at, "appliesTo", ...indexOf(patterns, doubt.pattern)],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const dead = new Set(compiled.invalid.map((bad) => bad.pattern));
|
|
103
|
+
// Structural, so it does not wait on a corpus: an exclusion that cannot
|
|
104
|
+
// meet any of this scope's includes subtracts nothing, whatever the
|
|
105
|
+
// workspace happens to hold. An exclusion that CAN meet them and matches
|
|
106
|
+
// no file today is a workspace with an empty archive folder, which is
|
|
107
|
+
// caution rather than error and is deliberately silent.
|
|
108
|
+
for (const pattern of findUnreachableNegations(appliesTo)) {
|
|
109
|
+
if (dead.has(pattern))
|
|
110
|
+
continue;
|
|
111
|
+
out.push({
|
|
112
|
+
severity: "warning",
|
|
113
|
+
ruleId: CONFIG_RULE_IDS.deadNegation,
|
|
114
|
+
message: `\`${pattern}\` excludes a path this rule never includes, so it changes nothing.`,
|
|
115
|
+
at: [...at, "appliesTo", ...indexOf(patterns, pattern)],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (opts.knownPaths && opts.knownPaths.length > 0) {
|
|
119
|
+
for (const pattern of findZeroMatchPatterns(appliesTo, opts.knownPaths)) {
|
|
120
|
+
// An invalid pattern matches nothing BECAUSE it is invalid; saying so
|
|
121
|
+
// twice is noise.
|
|
122
|
+
if (dead.has(pattern))
|
|
123
|
+
continue;
|
|
124
|
+
out.push({
|
|
125
|
+
severity: "info",
|
|
126
|
+
ruleId: CONFIG_RULE_IDS.emptyScope,
|
|
127
|
+
message: `\`${pattern}\` matches none of this workspace's documents.`,
|
|
128
|
+
at: [...at, "appliesTo", ...indexOf(patterns, pattern)],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// The schema declarations, graded by the module that reads them. Their `at`
|
|
134
|
+
// paths are indices into the declaration list, so they are re-rooted here to
|
|
135
|
+
// be paths into the CONFIG — a settings UI gets one address space, not two.
|
|
136
|
+
for (const diagnostic of lintFrontmatterSchemas(config?.frontmatterSchemas)) {
|
|
137
|
+
out.push({
|
|
138
|
+
...diagnostic,
|
|
139
|
+
at: ["frontmatterSchemas", ...diagnostic.at],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Apply the config to the registry.
|
|
146
|
+
*
|
|
147
|
+
* A rule set to `off` is dropped here rather than filtered later, so a
|
|
148
|
+
* disabled rule costs no work at all. A rule with a scope keeps it: whether it
|
|
149
|
+
* runs depends on the document's path, which the runner knows and this
|
|
150
|
+
* function does not.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveLintRules(rules, config) {
|
|
153
|
+
const settings = config?.rules;
|
|
154
|
+
if (!settings)
|
|
155
|
+
return rules.map((rule) => ({ rule }));
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const rule of rules) {
|
|
158
|
+
const entry = settings[rule.id];
|
|
159
|
+
if (entry?.severity === "off")
|
|
160
|
+
continue;
|
|
161
|
+
out.push({
|
|
162
|
+
rule,
|
|
163
|
+
...(entry?.severity !== undefined && isLintSeverity(entry.severity)
|
|
164
|
+
? { severity: entry.severity }
|
|
165
|
+
: {}),
|
|
166
|
+
...(entry?.appliesTo !== undefined
|
|
167
|
+
? { scope: compileAppliesTo(entry.appliesTo) }
|
|
168
|
+
: {}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Does a scoped rule run on this document?
|
|
175
|
+
*
|
|
176
|
+
* Fail-OPEN when the caller gave no path. A scope narrows a rule; a document
|
|
177
|
+
* whose path we do not know cannot be shown to fall outside one, and a linter
|
|
178
|
+
* of laws would rather post a mark the workspace scoped away than go quiet on
|
|
179
|
+
* a document it simply could not place. Every caller that HAS a path passes
|
|
180
|
+
* it, and the editor is the only one that sometimes does not.
|
|
181
|
+
*/
|
|
182
|
+
export function scopeAdmits(resolved, path) {
|
|
183
|
+
if (!resolved.scope)
|
|
184
|
+
return true;
|
|
185
|
+
if (path === undefined || path === "")
|
|
186
|
+
return true;
|
|
187
|
+
return resolved.scope.matches(path);
|
|
188
|
+
}
|
|
189
|
+
function patternList(appliesTo) {
|
|
190
|
+
return typeof appliesTo === "string" ? [appliesTo] : [...appliesTo];
|
|
191
|
+
}
|
|
192
|
+
/** `["appliesTo", 2]`, or just `["appliesTo"]` when the pattern is not found. */
|
|
193
|
+
function indexOf(patterns, pattern) {
|
|
194
|
+
const index = patterns.findIndex((p) => p.trim() === pattern.trim());
|
|
195
|
+
return index === -1 ? [] : [index];
|
|
196
|
+
}
|
|
197
|
+
/** The closest real rule id, when one is close enough to be worth naming. */
|
|
198
|
+
function nearest(ruleId, known) {
|
|
199
|
+
const bare = ruleId.includes("/") ? ruleId.slice(ruleId.indexOf("/") + 1) : ruleId;
|
|
200
|
+
for (const candidate of known) {
|
|
201
|
+
if (candidate === `sfora/${bare}`)
|
|
202
|
+
return ` Did you mean \`${candidate}\`?`;
|
|
203
|
+
}
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { LintFix, LintSourceOptions, SforaDiagnostic } from "./types.js";
|
|
2
|
+
export interface AppliedFix {
|
|
3
|
+
ruleId: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** The diagnostic's own span, in the source this pass read. */
|
|
6
|
+
from: number;
|
|
7
|
+
to: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SkippedFix extends AppliedFix {
|
|
10
|
+
/** `conflict`: an earlier fix already claimed these bytes.
|
|
11
|
+
* `invalid`: the fix's edits do not compose over this source. */
|
|
12
|
+
reason: "conflict" | "invalid";
|
|
13
|
+
}
|
|
14
|
+
export interface FixAllResult {
|
|
15
|
+
text: string;
|
|
16
|
+
applied: AppliedFix[];
|
|
17
|
+
skipped: SkippedFix[];
|
|
18
|
+
}
|
|
19
|
+
export interface FixAllOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Which fix to take when a diagnostic offers several. Defaults to the first,
|
|
22
|
+
* which every rule in this package orders as its preferred repair.
|
|
23
|
+
*/
|
|
24
|
+
pick?: (diagnostic: SforaDiagnostic) => LintFix | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compose every diagnostic's preferred fix over `source` in one pass.
|
|
28
|
+
*
|
|
29
|
+
* Diagnostics are taken in the order given — `lintSource` returns them in
|
|
30
|
+
* document order, which is what makes "the first one wins" a stable rule
|
|
31
|
+
* rather than a coin toss.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fixAll(source: string, diagnostics: readonly SforaDiagnostic[], opts?: FixAllOptions): FixAllResult;
|
|
34
|
+
export interface LintAndFixResult extends FixAllResult {
|
|
35
|
+
/** What is still wrong after the last pass. The honest bottom line. */
|
|
36
|
+
remaining: SforaDiagnostic[];
|
|
37
|
+
}
|
|
38
|
+
export interface LintAndFixOptions extends LintSourceOptions, FixAllOptions {
|
|
39
|
+
/**
|
|
40
|
+
* How many lint-then-fix rounds to run. More than one because a repair can
|
|
41
|
+
* uncover a fixable diagnostic that the broken bytes were hiding; a small
|
|
42
|
+
* cap because a rule pair that undoes each other's work would otherwise
|
|
43
|
+
* spin forever. A pass that changes nothing ends the loop early.
|
|
44
|
+
*/
|
|
45
|
+
passes?: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Lint, fix everything fixable, and report what is left.
|
|
49
|
+
*
|
|
50
|
+
* The whole autofix surface in one call: give it bytes, get back better bytes
|
|
51
|
+
* plus an account of what happened to them.
|
|
52
|
+
*
|
|
53
|
+
* The two accounts are kept differently, on purpose. `applied` ACCUMULATES:
|
|
54
|
+
* every repair that landed, landed, and stays in the record. `skipped` is only
|
|
55
|
+
* the LAST round's refusals — a fix refused for conflict in pass one is
|
|
56
|
+
* usually applied in pass two, once the fix it collided with has moved out of
|
|
57
|
+
* the way, and carrying it forward would report the same repair as both done
|
|
58
|
+
* and refused. What `skipped` answers is "what did this call decline to do to
|
|
59
|
+
* the text it is handing back", which is a question about the end state.
|
|
60
|
+
* `remaining` is the final lint over the final text.
|
|
61
|
+
*/
|
|
62
|
+
export declare function lintAndFixAll(source: string, opts?: LintAndFixOptions): LintAndFixResult;
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
// Fix-all: every quick fix in a document, composed into one change.
|
|
4
|
+
//
|
|
5
|
+
// The naive version — apply a fix, re-lint, apply the next — is quadratic and
|
|
6
|
+
// wrong in a subtler way: each re-lint sees a document the author never wrote,
|
|
7
|
+
// so the second fix is computed against the first fix's opinion. What this
|
|
8
|
+
// does instead is treat the whole pass as ONE atomic change over the ORIGINAL
|
|
9
|
+
// source. Every fix's offsets stay valid because nothing has moved yet; the
|
|
10
|
+
// accepted edits go in together, reverse-sorted, at the end.
|
|
11
|
+
//
|
|
12
|
+
// That leaves exactly one question: what happens when two rules want the same
|
|
13
|
+
// bytes. `[[]]` inside a dropped `status` line has a fix from each. There is no
|
|
14
|
+
// correct merge — the two edits were computed in ignorance of one another —
|
|
15
|
+
// so the first in document order wins and the second is REPORTED as skipped
|
|
16
|
+
// rather than dropped. A batch that silently discards half its work is how an
|
|
17
|
+
// agent ends up believing a document is clean.
|
|
18
|
+
//
|
|
19
|
+
// The reporting is the point. `applied` and `skipped` are what make this
|
|
20
|
+
// usable without a human watching: an agent PUTs a file, gets back what was
|
|
21
|
+
// repaired, what was refused and why, and what is still wrong. Nothing here
|
|
22
|
+
// touches the network — it is the mechanism such a surface would be four lines
|
|
23
|
+
// of glue over.
|
|
24
|
+
import { lintSource } from "./lintSource.js";
|
|
25
|
+
import { checkEdits, editsConflict, tryApplyEdits } from "./textEdits.js";
|
|
26
|
+
/**
|
|
27
|
+
* Compose every diagnostic's preferred fix over `source` in one pass.
|
|
28
|
+
*
|
|
29
|
+
* Diagnostics are taken in the order given — `lintSource` returns them in
|
|
30
|
+
* document order, which is what makes "the first one wins" a stable rule
|
|
31
|
+
* rather than a coin toss.
|
|
32
|
+
*/
|
|
33
|
+
export function fixAll(source, diagnostics, opts = {}) {
|
|
34
|
+
const pick = opts.pick ?? ((d) => d.fixes?.[0]);
|
|
35
|
+
const accepted = [];
|
|
36
|
+
const applied = [];
|
|
37
|
+
const skipped = [];
|
|
38
|
+
for (const diagnostic of diagnostics) {
|
|
39
|
+
const fix = pick(diagnostic);
|
|
40
|
+
// A diagnostic with no fix is not a skip — nothing was refused. It shows
|
|
41
|
+
// up in `remaining` instead, which is where an author looks for work.
|
|
42
|
+
if (!fix || fix.edits.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
const where = {
|
|
45
|
+
ruleId: diagnostic.ruleId,
|
|
46
|
+
label: fix.label,
|
|
47
|
+
from: diagnostic.from,
|
|
48
|
+
to: diagnostic.to,
|
|
49
|
+
};
|
|
50
|
+
if (checkEdits(source, fix.edits) !== null) {
|
|
51
|
+
skipped.push({ ...where, reason: "invalid" });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const clashes = fix.edits.some((edit) => accepted.some((taken) => editsConflict(edit, taken)));
|
|
55
|
+
if (clashes) {
|
|
56
|
+
skipped.push({ ...where, reason: "conflict" });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
accepted.push(...fix.edits);
|
|
60
|
+
applied.push(where);
|
|
61
|
+
}
|
|
62
|
+
const result = tryApplyEdits(source, accepted);
|
|
63
|
+
// `accepted` is conflict-free and in range by construction, so the failure
|
|
64
|
+
// branch is unreachable — and it returns the untouched source rather than
|
|
65
|
+
// throwing, because a linter is never the reason a document is damaged.
|
|
66
|
+
return {
|
|
67
|
+
text: result.ok ? result.text : source,
|
|
68
|
+
applied,
|
|
69
|
+
skipped,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Lint, fix everything fixable, and report what is left.
|
|
74
|
+
*
|
|
75
|
+
* The whole autofix surface in one call: give it bytes, get back better bytes
|
|
76
|
+
* plus an account of what happened to them.
|
|
77
|
+
*
|
|
78
|
+
* The two accounts are kept differently, on purpose. `applied` ACCUMULATES:
|
|
79
|
+
* every repair that landed, landed, and stays in the record. `skipped` is only
|
|
80
|
+
* the LAST round's refusals — a fix refused for conflict in pass one is
|
|
81
|
+
* usually applied in pass two, once the fix it collided with has moved out of
|
|
82
|
+
* the way, and carrying it forward would report the same repair as both done
|
|
83
|
+
* and refused. What `skipped` answers is "what did this call decline to do to
|
|
84
|
+
* the text it is handing back", which is a question about the end state.
|
|
85
|
+
* `remaining` is the final lint over the final text.
|
|
86
|
+
*/
|
|
87
|
+
export function lintAndFixAll(source, opts = {}) {
|
|
88
|
+
const passes = Math.max(1, Math.min(opts.passes ?? 3, 10));
|
|
89
|
+
let text = source;
|
|
90
|
+
const applied = [];
|
|
91
|
+
let skipped = [];
|
|
92
|
+
let diagnostics = lintSource(text, opts);
|
|
93
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
94
|
+
const round = fixAll(text, diagnostics, opts);
|
|
95
|
+
applied.push(...round.applied);
|
|
96
|
+
// Only the LAST round's refusals survive. A fix skipped for conflict in
|
|
97
|
+
// pass one is usually applied in pass two, once the fix it collided with
|
|
98
|
+
// has landed and moved out of the way; carrying the skip forward would
|
|
99
|
+
// report work that was refused and then done.
|
|
100
|
+
skipped = round.skipped;
|
|
101
|
+
if (round.text === text)
|
|
102
|
+
break;
|
|
103
|
+
text = round.text;
|
|
104
|
+
diagnostics = lintSource(text, opts);
|
|
105
|
+
}
|
|
106
|
+
return { text, applied, skipped, remaining: diagnostics };
|
|
107
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { type CompiledAppliesTo } from "./appliesTo.js";
|
|
2
|
+
import { type LintSeverity } from "./severity.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every keyword this module reads, and what it does with it.
|
|
5
|
+
*
|
|
6
|
+
* The list is exported because it is the contract: {@link lintFrontmatterSchemas}
|
|
7
|
+
* reports anything outside it, and the test that keeps the two honest reads
|
|
8
|
+
* this object rather than a second copy of the list.
|
|
9
|
+
*
|
|
10
|
+
* `annotation` keywords are read by nobody and reported by nobody — they are
|
|
11
|
+
* documentation, and a schema that carries a `description` for its agents is
|
|
12
|
+
* doing the right thing.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SUPPORTED_KEYWORDS: Readonly<Record<string, "constraint" | "annotation">>;
|
|
15
|
+
/**
|
|
16
|
+
* The `format` values that mean something here.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately five, and deliberately the five a sfora frontmatter block
|
|
19
|
+
* actually holds — a date, a timestamp, a link, an address, a slug. A `format`
|
|
20
|
+
* outside this set is reported rather than ignored, which is the whole policy
|
|
21
|
+
* of this file in one line.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SUPPORTED_FORMATS: readonly ["date", "date-time", "uri", "email", "slug"];
|
|
24
|
+
export type FrontmatterFormat = (typeof SUPPORTED_FORMATS)[number];
|
|
25
|
+
/** A JSON Schema as authored. Untyped on purpose: it arrives as data. */
|
|
26
|
+
export type FrontmatterSchema = Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* A workspace's declaration: this schema, for this kind of document, on these
|
|
29
|
+
* paths.
|
|
30
|
+
*
|
|
31
|
+
* `kind` is not a scoping mechanism — `appliesTo` is. It is the WORD the
|
|
32
|
+
* diagnostic uses, so the author reads "a `decision` document needs a `status`"
|
|
33
|
+
* rather than "frontmatter property \"status\" is required". A rule that says
|
|
34
|
+
* which house rule it is enforcing is a rule somebody can go and read.
|
|
35
|
+
*/
|
|
36
|
+
export interface FrontmatterSchemaDeclaration {
|
|
37
|
+
/** `decision`, `runbook`, `post`. Appears in every message the schema makes. */
|
|
38
|
+
kind: string;
|
|
39
|
+
/** Path globs, the `appliesTo` dialect. Absent means every document. */
|
|
40
|
+
appliesTo?: string | readonly string[];
|
|
41
|
+
schema: FrontmatterSchema;
|
|
42
|
+
/**
|
|
43
|
+
* What this schema's marks report at.
|
|
44
|
+
*
|
|
45
|
+
* Absent means the per-constraint defaults in {@link severityFor}, which are
|
|
46
|
+
* the honest reading of the four-level ladder in ./severity: a key the
|
|
47
|
+
* document was supposed to carry and does not is a `warning` (the document
|
|
48
|
+
* will be read as saying something it does not say), and a key nothing in
|
|
49
|
+
* the schema knows about is an `info` (the author wrote it and nothing will
|
|
50
|
+
* ever read it — the ladder's definition of `info`, exactly).
|
|
51
|
+
*/
|
|
52
|
+
severity?: LintSeverity;
|
|
53
|
+
}
|
|
54
|
+
/** A declaration with its scope compiled, ready to run on one document. */
|
|
55
|
+
export interface SelectedFrontmatterSchema {
|
|
56
|
+
declaration: FrontmatterSchemaDeclaration;
|
|
57
|
+
scope: CompiledAppliesTo;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The declarations that apply to `path`.
|
|
61
|
+
*
|
|
62
|
+
* Fail-CLOSED where `scopeAdmits` fails open, and the asymmetry is deliberate.
|
|
63
|
+
* A scoped RULE narrows something that would otherwise run everywhere, so a
|
|
64
|
+
* document whose path we do not know keeps the rule (see `config.ts`). A
|
|
65
|
+
* frontmatter schema is the opposite: it exists only for a kind of document,
|
|
66
|
+
* and running a `decision` schema over an unknown document would report a
|
|
67
|
+
* missing `status` on a chat message. Silence is the cheaper failure here, and
|
|
68
|
+
* a declaration with NO `appliesTo` still runs on everything, which is the door
|
|
69
|
+
* for a workspace that means it.
|
|
70
|
+
*/
|
|
71
|
+
export declare function selectFrontmatterSchemas(declarations: readonly FrontmatterSchemaDeclaration[] | undefined, path: string | undefined): SelectedFrontmatterSchema[];
|
|
72
|
+
/** Which constraint a violation came from. Keys the default severity map. */
|
|
73
|
+
export type FrontmatterViolationKind =
|
|
74
|
+
/** A `required` key the document does not have. */
|
|
75
|
+
"missing"
|
|
76
|
+
/** A key the schema constrains, holding a value the constraint refuses. */
|
|
77
|
+
| "invalid"
|
|
78
|
+
/** A key no `properties` entry names, under `additionalProperties: false`. */
|
|
79
|
+
| "unknown";
|
|
80
|
+
export interface FrontmatterViolation {
|
|
81
|
+
kind: FrontmatterViolationKind;
|
|
82
|
+
/** The top-level frontmatter key, or `""` for a whole-block complaint. */
|
|
83
|
+
key: string;
|
|
84
|
+
/** The document kind the schema was declared for. */
|
|
85
|
+
documentKind: string;
|
|
86
|
+
/** The schema keyword that refused it — `required`, `enum`, `pattern`, … */
|
|
87
|
+
keyword: string;
|
|
88
|
+
message: string;
|
|
89
|
+
severity: LintSeverity;
|
|
90
|
+
/**
|
|
91
|
+
* A value that would satisfy the constraint, when there is exactly one
|
|
92
|
+
* obvious candidate. The rule turns it into a quick fix; nothing else reads
|
|
93
|
+
* it, and it is absent far more often than it is present.
|
|
94
|
+
*/
|
|
95
|
+
suggestion?: string;
|
|
96
|
+
}
|
|
97
|
+
/** A YAML value as sfora parses it: a scalar or a flat list of scalars. */
|
|
98
|
+
type FrontmatterValue = string | string[];
|
|
99
|
+
/**
|
|
100
|
+
* Check one document's frontmatter against every schema declared for it.
|
|
101
|
+
*
|
|
102
|
+
* `data` is what `markdown/yaml.ts` read, so this function grades the values
|
|
103
|
+
* SFORA WILL ACTUALLY USE rather than a second reading of the same bytes. That
|
|
104
|
+
* matters more than it sounds: a key the tiny YAML drops (no colon, a nested
|
|
105
|
+
* map) is a key the workspace does not have, and a validator that read it with
|
|
106
|
+
* a fuller parser would pass a document whose `status` no consumer can see.
|
|
107
|
+
* The structural half of that — telling the author their line was dropped —
|
|
108
|
+
* belongs to `malformed-frontmatter`, which is why this rule stays silent about
|
|
109
|
+
* it instead of saying the same thing twice.
|
|
110
|
+
*/
|
|
111
|
+
export declare function validateFrontmatter(data: Readonly<Record<string, FrontmatterValue>>, schemas: readonly SelectedFrontmatterSchema[]): FrontmatterViolation[];
|
|
112
|
+
/**
|
|
113
|
+
* Read a frontmatter block's key lines.
|
|
114
|
+
*
|
|
115
|
+
* Same reading as `parseYaml` — a top-level `key:` and nothing indented — so
|
|
116
|
+
* the line a mark lands on is the line the value came from. Duplicate keys
|
|
117
|
+
* resolve to the FIRST occurrence, which is where `malformed-frontmatter`
|
|
118
|
+
* already points its own duplicate mark, so the two rules agree about which
|
|
119
|
+
* line a key is on.
|
|
120
|
+
*/
|
|
121
|
+
export declare function frontmatterKeyLines(lines: readonly string[], block: {
|
|
122
|
+
open: number;
|
|
123
|
+
close: number;
|
|
124
|
+
}): Map<string, number>;
|
|
125
|
+
/** The block's body, for `parseYaml`. */
|
|
126
|
+
export declare function frontmatterBody(lines: readonly string[], block: {
|
|
127
|
+
open: number;
|
|
128
|
+
close: number;
|
|
129
|
+
}): string;
|
|
130
|
+
/** Read a document's frontmatter the way sfora reads it. */
|
|
131
|
+
export declare function readFrontmatter(lines: readonly string[], block: {
|
|
132
|
+
open: number;
|
|
133
|
+
close: number;
|
|
134
|
+
}): Record<string, FrontmatterValue>;
|
|
135
|
+
export declare const FRONTMATTER_SCHEMA_RULE_IDS: {
|
|
136
|
+
readonly unknownKeyword: "sfora/schema-unknown-keyword";
|
|
137
|
+
readonly unsupportedType: "sfora/schema-unsupported-type";
|
|
138
|
+
readonly unsupportedFormat: "sfora/schema-unsupported-format";
|
|
139
|
+
readonly invalidPattern: "sfora/schema-invalid-pattern";
|
|
140
|
+
readonly invalidSeverity: "sfora/schema-invalid-severity";
|
|
141
|
+
readonly invalidGlob: "sfora/schema-invalid-glob";
|
|
142
|
+
readonly suspiciousGlob: "sfora/schema-suspicious-glob";
|
|
143
|
+
readonly requiredNotDeclared: "sfora/schema-required-not-declared";
|
|
144
|
+
readonly emptyKind: "sfora/schema-no-kind";
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* A complaint about a SCHEMA rather than about a document.
|
|
148
|
+
*
|
|
149
|
+
* The same shape `lintLintConfig` returns, and for the same reason: a schema is
|
|
150
|
+
* an object and may never have been text, so there are no byte offsets to give.
|
|
151
|
+
* `at` is a JSON path into the declaration list — `[0, "schema", "properties",
|
|
152
|
+
* "status", "format"]` — which a settings UI turns into a field.
|
|
153
|
+
*/
|
|
154
|
+
export interface FrontmatterSchemaDiagnostic {
|
|
155
|
+
severity: LintSeverity;
|
|
156
|
+
ruleId: string;
|
|
157
|
+
message: string;
|
|
158
|
+
at: (string | number)[];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Types {@link matchesType} can answer for. `object` is deliberately absent.
|
|
162
|
+
*
|
|
163
|
+
* Exported for the same reason {@link SUPPORTED_FORMATS} is: it is a promise
|
|
164
|
+
* about behaviour, and its test drives the spelling each type accepts off this
|
|
165
|
+
* list rather than off a second copy of it. A type added here without a reading
|
|
166
|
+
* in `matchesType` fails that test.
|
|
167
|
+
*/
|
|
168
|
+
export declare const SUPPORTED_TYPES: readonly ["string", "array", "number", "integer", "boolean", "null"];
|
|
169
|
+
/**
|
|
170
|
+
* Read the declarations back and report every part of them that will silently
|
|
171
|
+
* do nothing.
|
|
172
|
+
*
|
|
173
|
+
* This is the promise that makes a supported SUBSET honest. An author writes a
|
|
174
|
+
* schema, this module reads the keywords it knows, and without this function
|
|
175
|
+
* every keyword it does not know would be a constraint the author believes is
|
|
176
|
+
* being enforced and that nothing enforces. `config.ts` calls that "the worst
|
|
177
|
+
* thing a linter can do, because the symptom is a clean document"; a validator
|
|
178
|
+
* has the same failure mode one layer up.
|
|
179
|
+
*/
|
|
180
|
+
export declare function lintFrontmatterSchemas(declarations: readonly FrontmatterSchemaDeclaration[] | undefined): FrontmatterSchemaDiagnostic[];
|
|
181
|
+
export {};
|