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.
Files changed (68) hide show
  1. package/README.md +139 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +243 -4
  4. package/dist/api-client.js +248 -20
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +317 -26
  8. package/dist/format/blockSplice.d.ts +135 -0
  9. package/dist/format/blockSplice.js +330 -0
  10. package/dist/format/blocks/dropClosure.d.ts +10 -1
  11. package/dist/format/blocks/dropClosure.js +11 -1
  12. package/dist/format/callout.d.ts +69 -7
  13. package/dist/format/callout.js +112 -15
  14. package/dist/format/checklist.js +11 -4
  15. package/dist/format/formatAxes.d.ts +228 -0
  16. package/dist/format/formatAxes.js +454 -0
  17. package/dist/format/index.d.ts +1 -0
  18. package/dist/format/index.js +4 -0
  19. package/dist/format/lineGeometry.d.ts +34 -4
  20. package/dist/format/lineGeometry.js +140 -40
  21. package/dist/format/lint/appliesTo.d.ts +92 -0
  22. package/dist/format/lint/appliesTo.js +369 -0
  23. package/dist/format/lint/config.d.ts +106 -0
  24. package/dist/format/lint/config.js +205 -0
  25. package/dist/format/lint/fixAll.d.ts +62 -0
  26. package/dist/format/lint/fixAll.js +107 -0
  27. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  28. package/dist/format/lint/frontmatterSchema.js +660 -0
  29. package/dist/format/lint/index.d.ts +34 -5
  30. package/dist/format/lint/index.js +34 -5
  31. package/dist/format/lint/lintSource.d.ts +27 -7
  32. package/dist/format/lint/lintSource.js +67 -33
  33. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  34. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  35. package/dist/format/lint/rules/index.d.ts +2 -1
  36. package/dist/format/lint/rules/index.js +7 -1
  37. package/dist/format/lint/rules/malformed-callout.js +25 -16
  38. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  39. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  40. package/dist/format/lint/severity.d.ts +15 -0
  41. package/dist/format/lint/severity.js +50 -0
  42. package/dist/format/lint/textEdits.d.ts +86 -0
  43. package/dist/format/lint/textEdits.js +162 -0
  44. package/dist/format/lint/types.d.ts +44 -8
  45. package/dist/format/markdown/slug.d.ts +28 -0
  46. package/dist/format/markdown/slug.js +63 -0
  47. package/dist/format/plaintext.js +13 -3
  48. package/dist/format/sheetCellSpans.d.ts +95 -0
  49. package/dist/format/sheetCellSpans.js +223 -0
  50. package/dist/format/sheetSelection.d.ts +136 -0
  51. package/dist/format/sheetSelection.js +282 -0
  52. package/dist/format/textStats.d.ts +23 -0
  53. package/dist/format/textStats.js +80 -0
  54. package/dist/format/wikiLinks.d.ts +60 -1
  55. package/dist/format/wikiLinks.js +195 -9
  56. package/dist/index.d.ts +26 -1
  57. package/dist/index.js +20 -3
  58. package/dist/opener.d.ts +23 -0
  59. package/dist/opener.js +26 -0
  60. package/dist/render.d.ts +132 -0
  61. package/dist/render.js +208 -0
  62. package/dist/shell-commands.d.ts +34 -0
  63. package/dist/shell-commands.js +108 -0
  64. package/dist/watch.d.ts +79 -0
  65. package/dist/watch.js +113 -0
  66. package/dist/web-url.d.ts +39 -0
  67. package/dist/web-url.js +63 -0
  68. package/package.json +1 -1
@@ -45,6 +45,53 @@ export function frontmatterExtent(lines) {
45
45
  }
46
46
  return { open: 0, close: -1 }; // opened, never closed
47
47
  }
48
+ /**
49
+ * The first line of the document's markdown: 0, or the line after a leading
50
+ * frontmatter block. A frontmatter block that never closes takes the whole
51
+ * document with it, which is what a reader sees too.
52
+ *
53
+ * Lint and the mask both need this number and must not each compute it: one of
54
+ * them being wrong about where the YAML ends is exactly how a `<!--` in a
55
+ * summary field opens a comment over a whole file.
56
+ */
57
+ export function markdownStart(lines) {
58
+ const frontmatter = frontmatterExtent(lines);
59
+ if (frontmatter === null)
60
+ return 0;
61
+ return frontmatter.close === -1 ? lines.length : frontmatter.close + 1;
62
+ }
63
+ /** The fenced block opening at line `i`, or null if none opens there. */
64
+ function fenceRegionAt(lines, i) {
65
+ const match = FENCE_OPEN.exec(lineText(lines, i));
66
+ if (!match)
67
+ return null;
68
+ const delimiter = match[2];
69
+ const info = match[3];
70
+ // A backtick fence's info string may not contain a backtick — that rules
71
+ // out inline code like ```` ```a``` ```` opening a block.
72
+ if (delimiter.startsWith("`") && info.includes("`"))
73
+ return null;
74
+ const char = delimiter[0];
75
+ const closer = new RegExp(`^ {0,3}[${char}]{${delimiter.length},}[ \\t]*$`);
76
+ let close = lines.length - 1;
77
+ let closed = false;
78
+ for (let j = i + 1; j < lines.length; j++) {
79
+ if (closer.test(lineText(lines, j))) {
80
+ close = j;
81
+ closed = true;
82
+ break;
83
+ }
84
+ }
85
+ return {
86
+ open: i,
87
+ close,
88
+ closed,
89
+ lang: info.trim().split(/\s+/)[0]?.toLowerCase() ?? "",
90
+ indent: match[1].length,
91
+ contentStart: i + 1,
92
+ contentEnd: closed ? close : lines.length,
93
+ };
94
+ }
48
95
  /**
49
96
  * Every fenced code block in the document, in order. `from` skips the
50
97
  * frontmatter block, whose contents are not markdown either.
@@ -52,36 +99,11 @@ export function frontmatterExtent(lines) {
52
99
  export function fencedRegions(lines, from = 0) {
53
100
  const regions = [];
54
101
  for (let i = from; i < lines.length; i++) {
55
- const match = FENCE_OPEN.exec(lineText(lines, i));
56
- if (!match)
57
- continue;
58
- const delimiter = match[2];
59
- const info = match[3];
60
- // A backtick fence's info string may not contain a backtick — that rules
61
- // out inline code like ```` ```a``` ```` opening a block.
62
- if (delimiter.startsWith("`") && info.includes("`"))
102
+ const region = fenceRegionAt(lines, i);
103
+ if (region === null)
63
104
  continue;
64
- const char = delimiter[0];
65
- const closer = new RegExp(`^ {0,3}[${char}]{${delimiter.length},}[ \\t]*$`);
66
- let close = lines.length - 1;
67
- let closed = false;
68
- for (let j = i + 1; j < lines.length; j++) {
69
- if (closer.test(lineText(lines, j))) {
70
- close = j;
71
- closed = true;
72
- break;
73
- }
74
- }
75
- regions.push({
76
- open: i,
77
- close,
78
- closed,
79
- lang: info.trim().split(/\s+/)[0]?.toLowerCase() ?? "",
80
- indent: match[1].length,
81
- contentStart: i + 1,
82
- contentEnd: closed ? close : lines.length,
83
- });
84
- i = close;
105
+ regions.push(region);
106
+ i = region.close;
85
107
  }
86
108
  return regions;
87
109
  }
@@ -100,10 +122,11 @@ export function fenceMask(lines, from = 0) {
100
122
  * null when the run never closes.
101
123
  *
102
124
  * CommonMark wants the closing run to be EXACTLY as long as the opening one,
103
- * which is why this is a scan and not an `indexOf`: in `` `a``b` `` the double
104
- * run does not close the single, and a search for "one or more backticks"
105
- * ends the span three characters early. An unterminated run opens nothing —
106
- * a stray backtick must not swallow the rest of the line.
125
+ * which is why this is a scan and not an `indexOf`: in `` `a`` `` the double
126
+ * run does not close the single one and nothing else on the line does either,
127
+ * so there is no code span at all — where a search for the next backtick would
128
+ * end one after `a` and mask two characters of prose as code. An unterminated
129
+ * run opens nothing: a stray backtick must not swallow the rest of the line.
107
130
  */
108
131
  function codeSpanEnd(text, start) {
109
132
  let open = 0;
@@ -290,22 +313,99 @@ function maskLine(raw, state) {
290
313
  * length of the line it masks, so `masked[i][k]` and `lines[i][k]` are the
291
314
  * same byte position.
292
315
  *
293
- * `from` skips a leading frontmatter block for the FENCE scan only, mirroring
294
- * {@link fenceMask} — whether the frontmatter itself renders is the caller's
295
- * question, and lint already answers it with `inFrontmatter`.
316
+ * `from` is where the document's markdown begins — everything above it is a
317
+ * frontmatter block, which is not markdown at all and is returned verbatim for
318
+ * whoever does read it (lint, with `inFrontmatter`). Scanning it would be
319
+ * worse than useless: a `<!--` in a YAML value would open a comment over the
320
+ * whole document. Callers holding a BODY pass 0 — a leading `---` there is a
321
+ * thematic break, not frontmatter — and `maskNonRenderingContexts` works the
322
+ * boundary out with `markdownStart`.
323
+ *
324
+ * A fence decides the whole line, but only where nothing is open already: a
325
+ * ``` line inside an unterminated comment is comment text, and the document
326
+ * below the `-->` renders. That ordering is CommonMark's — an HTML block ends
327
+ * at its closing condition, not at the next thing that looks like a fence —
328
+ * and it is the one place fence geometry does not go first. Where the line is
329
+ * clear, `fenceRegionAt` answers, so there is still exactly one idea in the
330
+ * package of where a fence starts and ends.
331
+ *
332
+ * Lines are whatever the caller split; a bare `\r` inside one is not a line
333
+ * ending here. `maskNonRenderingContexts` splits the way CommonMark does.
296
334
  */
297
335
  export function maskNonRenderingLines(lines, from = 0) {
298
- const fenced = fenceMask(lines, from);
299
336
  const state = { comment: false, rawTag: null };
300
- return lines.map((raw, i) => fenced[i] ? " ".repeat(raw.length) : maskLine(raw, state));
337
+ const out = [];
338
+ let fenceEnd = -1;
339
+ for (let i = 0; i < lines.length; i++) {
340
+ const raw = lines[i];
341
+ if (i < from) {
342
+ out.push(raw);
343
+ continue;
344
+ }
345
+ if (i <= fenceEnd) {
346
+ out.push(" ".repeat(raw.length));
347
+ continue;
348
+ }
349
+ if (!state.comment && state.rawTag === null) {
350
+ const region = fenceRegionAt(lines, i);
351
+ if (region !== null) {
352
+ fenceEnd = region.close;
353
+ out.push(" ".repeat(raw.length));
354
+ continue;
355
+ }
356
+ }
357
+ out.push(maskLine(raw, state));
358
+ }
359
+ return out;
360
+ }
361
+ /**
362
+ * Split a document the way CommonMark does: `\n`, `\r\n` and a bare `\r` all
363
+ * end a line. The terminators are not part of the returned text and are never
364
+ * rewritten, which is how the mask stays byte-aligned with the source.
365
+ */
366
+ function documentLines(source) {
367
+ const lines = [];
368
+ let start = 0;
369
+ for (let i = 0; i < source.length; i++) {
370
+ const char = source[i];
371
+ if (char === "\n") {
372
+ lines.push({ text: source.slice(start, i), start });
373
+ start = i + 1;
374
+ }
375
+ else if (char === "\r") {
376
+ lines.push({ text: source.slice(start, i), start });
377
+ if (source[i + 1] === "\n")
378
+ i++;
379
+ start = i + 1;
380
+ }
381
+ }
382
+ lines.push({ text: source.slice(start), start });
383
+ return lines;
301
384
  }
302
385
  /**
303
386
  * The whole source, masked. `masked.length === source.length` always: the two
304
387
  * strings are the same document, one of them with the non-markdown blanked
305
388
  * out, and every offset holds across both.
389
+ *
390
+ * This is the DOCUMENT entry point — cm-lint and `demoteNonRenderingLinks`
391
+ * hand it whole files — so the frontmatter boundary is worked out here rather
392
+ * than assumed to be 0. Pass `from` explicitly when the caller knows better:
393
+ * 0 says "these bytes are all markdown", which is what a body is.
306
394
  */
307
- export function maskNonRenderingContexts(source) {
308
- return maskNonRenderingLines(source.split("\n")).join("\n");
395
+ export function maskNonRenderingContexts(source, from) {
396
+ const lines = documentLines(source);
397
+ const texts = lines.map((line) => line.text);
398
+ const masked = maskNonRenderingLines(texts, from ?? markdownStart(texts));
399
+ // Written back in place, never re-joined: the source keeps its own line
400
+ // terminators, whatever mix of them it has.
401
+ const out = source.split("");
402
+ for (let i = 0; i < lines.length; i++) {
403
+ const { start } = lines[i];
404
+ const text = masked[i];
405
+ for (let k = 0; k < text.length; k++)
406
+ out[start + k] = text[k];
407
+ }
408
+ return out.join("");
309
409
  }
310
410
  /**
311
411
  * True when the source span `[start, end)` holds bytes but no rendering ones —
@@ -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
+ }