sfora-cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. 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 {};