@taskless/cli 0.10.2 → 0.11.1

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.
@@ -0,0 +1,26 @@
1
+ import type { PlatformBinarySpec } from "./platform-binary";
2
+ /**
3
+ * ast-grep's per-platform packaging, as the shared resolver understands it.
4
+ *
5
+ * `toolchainSuffix: true` is what produces `@ast-grep/cli-linux-x64-gnu` and
6
+ * `-win32-x64-msvc`. The Vale packages set it false; see
7
+ * {@link PlatformBinarySpec} for why that distinction is load-bearing.
8
+ *
9
+ * Both `ast-grep` and `sg` are listed because the wrapper declares them as bin
10
+ * entries for the same target, so either may be what got linked. `ast-grep`
11
+ * leads because it is the name the platform package ships the binary under; the
12
+ * resolver reverses the list at the link-based tiers, so `sg` is still tried
13
+ * first there and named in the PATH advice, as it was before the shared
14
+ * resolver existed.
15
+ *
16
+ * ## Why this sits in its own module rather than in `scan.ts`
17
+ *
18
+ * The spec is data: five fields describing how a package is named and how the
19
+ * binary identifies itself. `scan.ts` is the scanner — it reaches
20
+ * `rules/engines.ts`, and through it `node:fs/promises` and the CLI's error
21
+ * type. `@taskless/cli/node/runtimes` publishes this spec so a consumer can
22
+ * resolve the same binary the CLI executes, and a consumer that wants a path
23
+ * has no business loading a scanner to get one. Vale's spec is already a leaf
24
+ * for the same reason: `rules/vale/binary.ts` imports nothing but the resolver.
25
+ */
26
+ export declare const AST_GREP_BINARY: PlatformBinarySpec;
@@ -0,0 +1,351 @@
1
+ /**
2
+ * What the two local engines can actually read.
3
+ *
4
+ * PURE DATA, DELIBERATELY. This module is imported by `src/prompts/recipes.ts`,
5
+ * which is a Worker-safe library surface — no citty, no telemetry, no
6
+ * filesystem, no network — and `assert-prompts-graph` in `vite.config.ts`
7
+ * fails the build if the prompts chunk's graph reaches a host capability. So
8
+ * nothing here may read `package.json`, spawn a binary, or import a node
9
+ * builtin. The values are transcribed once, here, and pinned by tests that do
10
+ * spawn the binaries.
11
+ *
12
+ * THE BINARIES ARE THE ONLY AUTHORITY, and neither of them can be asked at
13
+ * render time:
14
+ *
15
+ * - `src/generated/ast-grep-rule-schema.json` types `$defs.Language` as a bare
16
+ * string with no enum — its only hint is an `example` reading `"typescript"`,
17
+ * which is not even the canonical spelling — so the vendored schema cannot
18
+ * answer the question. `verify` answers it from the constants below instead
19
+ * (see `validateLanguage` in `verify.ts`).
20
+ * - `detect --json` reports the *repository's* languages in a different
21
+ * vocabulary — `C++` where ast-grep says `Cpp` — and says nothing about what
22
+ * an engine can parse.
23
+ * - Vale self-reports nothing at all. Its reach was measured by probing the
24
+ * shipped binary, which is why the Vale constants below carry a probe-shaped
25
+ * contract test rather than a parsed capability listing.
26
+ *
27
+ * BUMPING AN ENGINE IS A ONE-FILE EDIT. Change the version constant and the
28
+ * list beside it; `test/ast-grep-vendor-contract.test.ts` and
29
+ * `test/vale-vendor-contract.test.ts` fail until the two agree, which is the
30
+ * whole point of transcribing rather than describing. See taskless/cli#151 for
31
+ * the routing miss this exists to prevent: two GitHub Actions workflow rules
32
+ * were escalated to `runtime` because nothing said ast-grep parses YAML.
33
+ */
34
+ /**
35
+ * The ast-grep release pinned in `packages/cli/package.json`, both for
36
+ * `@ast-grep/cli` and for every `@ast-grep/cli-<platform>` optional dependency.
37
+ *
38
+ * Pinned against the binary by `test/ast-grep-vendor-contract.test.ts`
39
+ * ("engine capabilities" → "reports the pinned version").
40
+ */
41
+ export declare const AST_GREP_VERSION = "0.45.2";
42
+ /**
43
+ * Every language ast-grep can parse, verbatim from
44
+ * `sg run -h` → `Supported languages are: [...]` at
45
+ * {@link AST_GREP_VERSION}.
46
+ *
47
+ * SPELLINGS ARE ast-grep's, NOT ours and not `detect`'s. `Cpp`, `CSharp`,
48
+ * `JavaScript`, `Tsx` — a rule's `language:` field is handed to ast-grep
49
+ * unchanged, so the binary has the final opinion. MEASURED at 0.45.2: it
50
+ * accepts more than this list — case variants and a fixed set of extension
51
+ * aliases, both enumerated in {@link AST_GREP_LANGUAGE_ALIASES} — so an
52
+ * off-list spelling is not on its own an error. The two real failures are a
53
+ * name ast-grep does not know at all (`C#`), which aborts config parsing so
54
+ * every rule goes unreported, and a valid name for the wrong parser
55
+ * (`TypeScript` over `.tsx`), which reports nothing and reads as a clean
56
+ * codebase. `verify` catches both; see `verify.ts`.
57
+ *
58
+ * Pinned by set-equality against the binary in
59
+ * `test/ast-grep-vendor-contract.test.ts`, so a version bump that adds or drops
60
+ * a language fails there rather than silently narrowing what the router
61
+ * believes is buildable locally.
62
+ */
63
+ export declare const AST_GREP_LANGUAGES: readonly ["Bash", "C", "Cpp", "CSharp", "Css", "Dart", "Elixir", "Go", "Haskell", "Hcl", "Html", "Java", "JavaScript", "Json", "Kotlin", "Lua", "Markdown", "Nix", "Php", "Python", "Ruby", "Rust", "Scala", "Solidity", "Swift", "Tsx", "TypeScript", "Yaml"];
64
+ /** One of the spellings {@link AST_GREP_LANGUAGES} lists, canonically cased. */
65
+ export type AstGrepLanguage = (typeof AST_GREP_LANGUAGES)[number];
66
+ /**
67
+ * The spellings ast-grep also accepts that are not on the canonical list,
68
+ * mapped to the language each resolves to.
69
+ *
70
+ * Keys are lowercase because ast-grep's own matching is case-insensitive:
71
+ * `TYPESCRIPT`, `Cs` and `GOLANG` all resolve at 0.45.2. That makes the whole
72
+ * accepted vocabulary "the canonical list plus these, compared lowercased",
73
+ * which is what {@link resolveAstGrepLanguage} implements.
74
+ *
75
+ * Every value here was probed through a real config, because that is the only
76
+ * thing a rule file is ever fed to. That is deliberate: `sg run --lang` is a
77
+ * SEPARATE vocabulary and cannot stand in for this one. MEASURED at 0.45.2,
78
+ * the two agree on `C++` and `cxx` (both accepted) and on `C#` (both
79
+ * rejected), but `--lang` rejects with clap's own `is not supported!` before
80
+ * a rule is ever read, so it exercises a different code path and is not
81
+ * evidence about `language:`. At 0.41.0 they diverged outright: `--lang C++`
82
+ * was rejected while `language: C++` parsed. Nothing here probes `--lang`.
83
+ *
84
+ * THIS IS THE ONE LIST HERE THE BINARY CANNOT BE ASKED TO ENUMERATE. `sg run
85
+ * -h` prints the canonical list, so `AST_GREP_LANGUAGES` above is checked by
86
+ * set-equality against it; nothing prints the aliases. Each entry is instead
87
+ * pinned by *probing*, in the "language aliases" suite of
88
+ * `test/ast-grep-vendor-contract.test.ts`: every key is fed to the binary in a
89
+ * config and the resolution is read back out of the scan stream's own
90
+ * `language` field — ast-grep reports the canonical name it settled on, so the
91
+ * mapping is the binary's answer rather than ours. The same suite feeds a
92
+ * sweep of near-misses (`h`, `mjs`, `sh`, `tf`, `csx`) and asserts they are
93
+ * rejected, so a bump that ADDS an alias fails there too.
94
+ *
95
+ * Whitespace is not folded, deliberately: `language: "ts "` is rejected by the
96
+ * binary, so accepting it here would pass a rule that cannot run.
97
+ */
98
+ export declare const AST_GREP_LANGUAGE_ALIASES: Readonly<Record<string, AstGrepLanguage>>;
99
+ /**
100
+ * The language ast-grep would parse `spelling` as, or `undefined` if it would
101
+ * reject the config outright.
102
+ *
103
+ * `undefined` is the fatal case, not a stylistic one: an unrecognized name
104
+ * fails `SgLang` deserialization, which aborts parsing of the single config
105
+ * Taskless assembles for the run — so every *other* sg rule goes unreported
106
+ * with it.
107
+ */
108
+ export declare function resolveAstGrepLanguage(spelling: string): AstGrepLanguage | undefined;
109
+ /**
110
+ * The `.ts` / `.tsx` split — the one pair of ast-grep languages that share a
111
+ * family and read disjoint file extensions.
112
+ *
113
+ * MEASURED at 0.45.2: a `TypeScript` rule over a `.tsx` tree exits zero having
114
+ * matched nothing, and a `Tsx` rule scans `.tsx` only. That is the quiet
115
+ * failure of the two, because "no findings" is exactly what a clean codebase
116
+ * looks like. Pinned by "treats Tsx and TypeScript as different parsers, not
117
+ * aliases" in `test/ast-grep-vendor-contract.test.ts`.
118
+ *
119
+ * Kept to this pair deliberately. Every other language's extensions would be a
120
+ * second vendored table with no measured backing, and the trap only exists
121
+ * where two languages look like spellings of one thing.
122
+ */
123
+ export declare const AST_GREP_TSX_SPLIT: Readonly<Partial<Record<AstGrepLanguage, string>>>;
124
+ /**
125
+ * The Vale release carried by the `@taskless/vale-<platform>` packages pinned
126
+ * in `packages/cli/package.json`. Their npm versions append a build stamp,
127
+ * `<valeVersion>-<yyyymmddhhmmss>`, minted per publish by
128
+ * `.github/workflows/release-vale.yml`; this is the version Vale itself
129
+ * reports.
130
+ *
131
+ * The SHAPE rather than an example, deliberately. A literal stamp here has to
132
+ * be hand-carried on every bump, and it has gone one release out of date twice
133
+ * now, which is worse than no example: it reads as the current pin and is not
134
+ * one. The real value lives in `packages/cli/package.json`, where a reader can
135
+ * see all six at once and cannot be told a stale one.
136
+ *
137
+ * Pinned against the binary by `test/vale-vendor-contract.test.ts`
138
+ * ("engine capabilities" → "reports the pinned version").
139
+ */
140
+ export declare const VALE_VERSION = "3.20.0";
141
+ /**
142
+ * Which tier Vale routes an extension to.
143
+ *
144
+ * `converter:<program>` carries the program's own name in the tier, so one
145
+ * table row states both the tier and the thing a user would install. The name
146
+ * is the one Vale prints in its `E100` text, because that is the string a
147
+ * reader will search for.
148
+ */
149
+ export type ValeFormatTier =
150
+ /** Parsed in-process: the document is prose, its own syntax is skipped. */
151
+ "markup"
152
+ /** Parsed in-process: comment text is linted, the code body is invisible. */
153
+ | "comment"
154
+ /** No parser: the whole file is linted as one block of prose. */
155
+ | "plaintext"
156
+ /** Vale shells out to a program the `@taskless/vale-*` packages do not ship. */
157
+ | `converter:${string}`;
158
+ /**
159
+ * EVERY MEASURED VALE EXTENSION, AND ITS TIER. THE ONLY TABLE.
160
+ *
161
+ * Adding an extension is one line here; every list, glob, notice and test below
162
+ * derives from this record, so there is no second place to keep in step. Two
163
+ * branches once measured this independently and produced two tables that
164
+ * disagreed about six extensions — that is what this single record exists to
165
+ * make impossible.
166
+ *
167
+ * MEASURED, NOT DOCUMENTED, AND MEASURED BY A DISCRIMINATING PROBE. Ordinary
168
+ * prose fires in all three readable tiers, so it can never separate them. Each
169
+ * tier is pinned by the property only that tier has, in
170
+ * `test/vale-vendor-contract.test.ts`:
171
+ *
172
+ * - **markup** — a construct only a real parser skips yields ZERO (a fenced
173
+ * code block, an Org `#` line, an HTML comment).
174
+ * - **comment** — the token in a comment yields one finding and the same token
175
+ * on a bare non-comment line yields ZERO. A type that fires on both is the
176
+ * plaintext fallback wearing a code extension.
177
+ * - **plaintext** — a bare line yields a finding. Listed only where the tier is
178
+ * surprising: `.tex`, `.mkd` and `.mkdn` all look like markup and are not.
179
+ * Everything unnamed lands here too, which is why this tier does not need to
180
+ * be exhaustive.
181
+ * - **converter** — a non-zero exit whose output carries `E100` and the
182
+ * program's name.
183
+ *
184
+ * Measured spellings, not families. `.mdown` is native Markdown and `.mkd` and
185
+ * `.mkdn` are not; `.asc` is a third AsciiDoc spelling that crashes exactly
186
+ * like `.adoc`; `.ditamap` is plaintext while `.dita` needs `dita`. Case is
187
+ * part of the key — `.r` and `.R` were both measured, `.PY` was measured and is
188
+ * not comment-aware. Add a row only after probing it; the contract test refuses
189
+ * to take one on faith.
190
+ *
191
+ * A VERSION BUMP INVALIDATES THIS TABLE — RE-MEASURE THE WHOLE OF IT. The tiers
192
+ * are a property of {@link VALE_VERSION}'s binary, and the dangerous direction
193
+ * is a format Vale *learns*: an extension missing from this table is read as
194
+ * plain text today, but the moment Vale routes it to a converter the same
195
+ * omission is a crash that takes down every Vale rule in the run.
196
+ *
197
+ * NOTHING MOVED ACROSS 3.19.0 → 3.20.0, AND THE SECOND HALF OF THAT CLAIM IS
198
+ * THE ONE THAT COST SOMETHING. Every row below was re-probed against the
199
+ * 3.20.0 binary and none of them moved, which this table can report. It cannot
200
+ * report a format Vale *learned*, so that direction was checked at the source
201
+ * instead: the v3.19.0...v3.20.0 tree adds no `internal/lint/<format>.go` and
202
+ * no `internal/lint/code/<lang>.go`, and every existing one of those files
203
+ * changes by a single import line from the org rename. No format was learned,
204
+ * so there is no new row to add. Do that check on the next bump too — a green
205
+ * table is silence about the dangerous direction, not evidence against it.
206
+ *
207
+ * THE TABLE ALSO HELD ACROSS 3.18.0 → 3.19.0, AND THAT IS NOT THE SAME AS THE
208
+ * BUMP BEING FREE. Every row below was re-probed against the 3.19.0 binary and
209
+ * not one of them moved. What moved was a format Vale *learned*, which is
210
+ * precisely the case a table of existing rows cannot report: `.ex` and `.exs`
211
+ * gained comment and doc-attribute extraction, so they left the unnamed
212
+ * plaintext fallback for the `comment` tier and are new rows below.
213
+ *
214
+ * Read that direction carefully, because it is a narrowing rather than a gain.
215
+ * On 3.18.0 an Elixir file was linted as one block of prose, so a rule matching
216
+ * `[*.ex]` fired on identifiers and string literals as readily as on comments.
217
+ * On 3.19.0 the code body is invisible and only comments and `@doc` attributes
218
+ * are read. Findings disappear, no error is raised, and nothing but a re-probe
219
+ * would have told us. A format Vale learns is never a no-op: the benign version
220
+ * of it is this one, and the dangerous version is a converter (see `.typ`).
221
+ *
222
+ * `.mdx` stayed `markup` and changed underneath the tier: a JSX element's
223
+ * children are now read as the Markdown they are, so a Vale rule covers prose
224
+ * inside `<Steps>` or `<Aside>` that it previously skipped, and those children
225
+ * carry the element name as a `text.class.<name>` scope. The tier is the wrong
226
+ * instrument for that kind of change — it says a parser exists, not what the
227
+ * parser sees — which is the second reason a bump needs more than this table.
228
+ *
229
+ * The 3.17.1 → 3.18.0 bump is what the dangerous case looks like in practice.
230
+ * Every row was re-probed against the 3.18.0 binary then too, and eight moved,
231
+ * in three different directions — which is why "re-measure" is not boilerplate:
232
+ *
233
+ * - `.mdx` gained a native parser: `converter:mdx2vast` → `markup`. It is
234
+ * supported now, and `[*.{md,mdx}]` is a legitimate matcher again.
235
+ * - `.typ` gained a parser that shells out to `typst2vast`
236
+ * (https://docs.vale.sh/formats/typst): `plaintext` →
237
+ * `converter:typst2vast`. That is the dangerous direction — 3.17.1 read it as
238
+ * prose, and the same row on 3.18.0 crashes the run. It stays unsupported
239
+ * permanently, since we do not support formats needing an external program.
240
+ * - `.rmd` gained a real Markdown parser, so it left the "looks like markup and
241
+ * is not" list above: `plaintext` → `markup`.
242
+ * - `.qml` and `.scss` gained parsers that see their comments: `plaintext` →
243
+ * `comment`. Vale's docs had claimed both for years; on 3.17.1 the claim was
244
+ * measurably false and on 3.18.0 it is true.
245
+ * - `.qmd` (Quarto) and `.myst` (MyST) measured as `markup`, and `.qdoc` as
246
+ * `comment` — QDoc documentation lives in a doc-comment block, so it is
247
+ * comment extraction rather than the markup tier a first reading of the
248
+ * release notes suggests, and probing it with bare prose reads as no support
249
+ * at all. All three are new rows, none of them reachable on 3.17.1.
250
+ *
251
+ * PHP also changed without the release notes saying so: comment extraction now
252
+ * needs a real `<?php` tag, where 3.17.1 linted a bare `//` comment without
253
+ * one. The tier did not move, but the probe had to. Re-probe every row on the
254
+ * next bump, by the discriminating property and by each language's own comment
255
+ * syntax — a wrong delimiter reads exactly like absent support.
256
+ */
257
+ export declare const VALE_FORMAT_TIERS: Readonly<Record<string, ValeFormatTier>>;
258
+ /**
259
+ * Extensions Vale parses as markup: the whole document is prose, and the
260
+ * format's own non-prose constructs are excluded.
261
+ *
262
+ * The HTML entries carry a consequence worth stating to an author: prose
263
+ * outside an element is not linted, so a bare sentence in a `.html` file yields
264
+ * nothing.
265
+ */
266
+ export declare const VALE_MARKUP_EXTENSIONS: readonly string[];
267
+ /**
268
+ * Extensions where Vale lints **comment text only** and ignores the code body.
269
+ */
270
+ export declare const VALE_COMMENT_EXTENSIONS: readonly string[];
271
+ /**
272
+ * Extensions measured into the plaintext fallback whose spelling suggests
273
+ * otherwise.
274
+ *
275
+ * Not exhaustive and not meant to be — every unnamed extension is plaintext
276
+ * too. These are the ones an author would reasonably assume were parsed, so
277
+ * they are worth naming in a recipe rather than leaving to "everything else".
278
+ */
279
+ export declare const VALE_PLAINTEXT_EXTENSIONS: readonly string[];
280
+ /**
281
+ * The converter each converter-dependent extension needs, keyed by extension.
282
+ *
283
+ * The lookup `rules/vale/formats.ts` uses to name a converter in the skip
284
+ * notice. Keys are lowercase because every measured converter format is; a
285
+ * caller comparing an extension off the filesystem must lowercase it first, or
286
+ * `README.RST` becomes a crash on case-insensitive platforms only.
287
+ */
288
+ export declare const VALE_CONVERTER_BY_EXTENSION: Readonly<Record<string, string>>;
289
+ /** A format Vale supports upstream but cannot read without an external tool. */
290
+ export interface ValeConverterFormat {
291
+ /** Extensions Vale routes through this converter. */
292
+ extensions: readonly string[];
293
+ /** The executable or artifact Vale looks for, named in its own E100 text. */
294
+ converter: string;
295
+ }
296
+ /**
297
+ * Formats that fail rather than lint, because Vale shells out to a converter
298
+ * this CLI does not ship.
299
+ *
300
+ * SAY THIS ACCURATELY: Vale supports these formats. What is missing is the
301
+ * external program it delegates the parse to. The failure is environmental, and
302
+ * describing it as "Vale does not support reStructuredText" sends an author
303
+ * looking for the wrong fix.
304
+ *
305
+ * The blast radius is what makes this worth surfacing at routing time rather
306
+ * than at authoring time: Vale exits 2 with an `E100` runtime error and
307
+ * abandons the run, and `--no-exit` does not suppress it. One `.typ` file
308
+ * caught by a rule's glob takes down the entire Vale pass, including every
309
+ * other rule and every other file — so `[*.{md,typ}]` is not a slightly wider
310
+ * matcher than `[*.md]`, it is a broken one. (`[*.{md,mdx}]` was that example
311
+ * until 3.18.0 gave MDX a native parser — the membership of this tier is a
312
+ * property of {@link VALE_VERSION}, and so is the worked example.)
313
+ */
314
+ export declare const VALE_CONVERTER_DEPENDENT: readonly ValeConverterFormat[];
315
+ /**
316
+ * Vale's checker tag per converter-dependent extension, from the `E100` text.
317
+ *
318
+ * This is the host-independent half of the failure. The prose after the tag is
319
+ * not: `.xml` reports `xsltproc not found` where the program is absent and
320
+ * `no XSLT transform provided` where it is present, and the two are split by
321
+ * platform — macOS ships `/usr/bin/xsltproc`, the Linux CI image does not. A
322
+ * contract test that matched on the program name therefore passed locally and
323
+ * failed in CI, which is how this list came to exist.
324
+ *
325
+ * `.xml` is also the one entry whose converter is not sufficient on its own. An
326
+ * XSLT transform is document-specific, so there is no default to ship and
327
+ * installing `xsltproc` does not make `.xml` lintable — unlike `asciidoctor`,
328
+ * which genuinely fixes `.adoc`. That is why its `converter` names the
329
+ * stylesheet as well as the program.
330
+ */
331
+ export declare const VALE_CONVERTER_CHECKERS: Readonly<Record<string, string>>;
332
+ /** Every converter-dependent extension, flattened. */
333
+ export declare const VALE_CONVERTER_DEPENDENT_EXTENSIONS: readonly string[];
334
+ /** `Bash, C, Cpp, …` — the ast-grep language list as recipe prose. */
335
+ export declare function astGrepLanguageList(): string;
336
+ /** `.htm, .html, …` — Vale's markup extensions as recipe prose. */
337
+ export declare function valeMarkupList(): string;
338
+ /** `.c, .c++, …` — Vale's comment-only extensions as recipe prose. */
339
+ export declare function valeCommentList(): string;
340
+ /**
341
+ * `.mkd, .mkdn, …` — the plaintext extensions worth naming, as recipe prose.
342
+ *
343
+ * Rendered rather than written into the recipe because the surprising cases are
344
+ * exactly the ones a hand-written list gets wrong.
345
+ */
346
+ export declare function valePlaintextList(): string;
347
+ /**
348
+ * `.rst (needs rst2html), …` — Vale's converter-dependent formats as recipe
349
+ * prose, each naming the tool whose absence is the actual failure.
350
+ */
351
+ export declare function valeConverterList(): string;
@@ -0,0 +1,132 @@
1
+ import type { EngineName } from "./layout";
2
+ /**
3
+ * What `verify` enforces beyond the engine's own schema.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * A rule the engine executes correctly can still be refused. Those refusals are
8
+ * deliberate, but they are OURS, and a generator that never reads our recipes
9
+ * cannot know them. Published in the conformance corpus so an external eval can
10
+ * tell "your rule is wrong about the subject" from "your rule broke a house
11
+ * rule it was never told about" — two findings that want completely different
12
+ * responses.
13
+ *
14
+ * ## Why every entry has a test that triggers it
15
+ *
16
+ * A hand-maintained list of what code does goes stale, and this is not
17
+ * hypothetical: `create-sg-rule.md` told agents for months that
18
+ * "`verify` never reads `language`", which stopped being true when
19
+ * `validateLanguage` landed. Nothing failed, because prose has no test.
20
+ *
21
+ * `test/constraints.test.ts` builds a rule that violates each entry and
22
+ * asserts `verify` rejects it, keyed on `id`. An entry describing a check that
23
+ * no longer fires fails the suite; a check with no entry is invisible to that
24
+ * test and is the gap this list is trying to close, so add one when you add a
25
+ * check.
26
+ */
27
+ export interface RuleConstraint {
28
+ /** Stable key. Consumers branch on this; renaming is breaking. */
29
+ id: string;
30
+ engine: EngineName;
31
+ /**
32
+ * Which command refuses the rule.
33
+ *
34
+ * Load-bearing for a consumer's eval ORDER, not a detail. A `verify`
35
+ * constraint is decided from the files alone and can be checked before
36
+ * anything runs; a `test` constraint needs the fixtures to execute. Running
37
+ * the cross-comparison before `verify` passes measures the wrong thing, and
38
+ * treating a `test`-time refusal as a `verify` gap sends someone to the wrong
39
+ * layer.
40
+ *
41
+ * The split is not always where it looks. `verify` requires that a test FILE
42
+ * exists, by filename; whether that file is attributed to this rule is
43
+ * decided later, from the `id:` inside it.
44
+ */
45
+ enforcedBy: "verify" | "test";
46
+ /** One line, for a report that lists several. */
47
+ summary: string;
48
+ /** Why it exists, so a reader can tell a house rule from a bug. */
49
+ rationale: string;
50
+ }
51
+ /**
52
+ * One constraint a rule broke, paired with the message that reports it.
53
+ *
54
+ * Emitted alongside `errors` rather than replacing it. A consumer mapping a
55
+ * rejection back to the rationale we already wrote had only our wording to
56
+ * match on, and wording is not a contract: rephrasing an error message is not
57
+ * a breaking change, so a text match rots without anything reporting it.
58
+ *
59
+ * The message is repeated rather than joined to `errors` by index. An index
60
+ * join is a contract nobody can see, and it breaks the first time either side
61
+ * filters or reorders. Repeating the string lets a consumer ignore `errors`
62
+ * entirely.
63
+ */
64
+ export interface RuleViolation {
65
+ constraintId: RuleConstraintId;
66
+ message: string;
67
+ }
68
+ export declare const RULE_CONSTRAINTS: readonly [{
69
+ readonly id: "sg-id-matches-directory";
70
+ readonly engine: "sg";
71
+ readonly enforcedBy: "verify";
72
+ readonly summary: "A rule's `id:` must equal the directory it lives in.";
73
+ readonly rationale: "The directory name is the rule id: it is what `check` and `test` address, and what a person types to delete a rule. ast-grep registers the rule under the id in its body. With the two apart, `test` cannot find the rule at all, and `check` does run it but reports findings under a name no directory has, so nobody can locate what produced them.";
74
+ }, {
75
+ readonly id: "sg-regex-needs-kind";
76
+ readonly engine: "sg";
77
+ readonly enforcedBy: "verify";
78
+ readonly summary: "A `regex` needs a sibling `kind`, in `rule`, `constraints` and `utils`.";
79
+ readonly rationale: "A regex match with no kind to anchor it is ambiguous and slow: it is applied to every node rather than to the one shape the author meant. ast-grep accepts it, so the engine is not the thing that will tell you.";
80
+ }, {
81
+ readonly id: "sg-language-accepted";
82
+ readonly engine: "sg";
83
+ readonly enforcedBy: "verify";
84
+ readonly summary: "`language:` must be a spelling ast-grep itself uses; a resolvable but non-canonical one is a notice.";
85
+ readonly rationale: "An unrecognized name aborts config parsing, which takes every other sg rule in the project down with it and reports nothing. That is the loudest possible failure with the quietest possible symptom: a clean report.";
86
+ }, {
87
+ readonly id: "sg-files-globs-parse";
88
+ readonly engine: "sg";
89
+ readonly enforcedBy: "verify";
90
+ readonly summary: "`files:` globs must not name `.tsx` under TypeScript, or `.ts` under Tsx.";
91
+ readonly rationale: "A glob naming an extension the language cannot parse matches nothing, so the rule reports a clean codebase rather than an error. Only the TypeScript/Tsx pair is checked, and deliberately so: they are separate parsers rather than aliases, which is the one language/extension mismatch decidable from the rule file alone. No other extension is compared against `language`, so this is narrower than it first reads.";
92
+ }, {
93
+ readonly id: "sg-required-fields";
94
+ readonly engine: "sg";
95
+ readonly enforcedBy: "verify";
96
+ readonly summary: "`id`, `language`, `severity`, `message` and `rule` are required.";
97
+ readonly rationale: "ast-grep needs fewer of these than we do. The extras are what make a finding actionable and a rule addressable once it is on disk.";
98
+ }, {
99
+ readonly id: "sg-test-file-required";
100
+ readonly engine: "sg";
101
+ readonly enforcedBy: "verify";
102
+ readonly summary: "A rule must ship at least one test file under `.tests/`.";
103
+ readonly rationale: "A rule with no fixtures has shown neither that it fires nor that it stays quiet. `verify` requires the file; `test` requires the cases inside it to cover both.";
104
+ }, {
105
+ readonly id: "sg-fixture-id-matches-rule";
106
+ readonly engine: "sg";
107
+ readonly enforcedBy: "test";
108
+ readonly summary: "A test file's own `id:` must equal the rule id.";
109
+ readonly rationale: "Fixtures are attributed by the id inside the file, not by its name. A fixture carrying another rule's id is silently not counted, so a rule that ships one reads as a rule that shipped none. `verify` passes, because the FILE is there; `test` is where it bites.";
110
+ }];
111
+ /**
112
+ * The id of a constraint this CLI publishes.
113
+ *
114
+ * Derived from the list rather than declared beside it, so a violation can only
115
+ * name a constraint that is actually published. Attributing a rejection to an
116
+ * id no consumer can look up would be worse than attributing nothing.
117
+ */
118
+ export type RuleConstraintId = (typeof RULE_CONSTRAINTS)[number]["id"];
119
+ /**
120
+ * Record an attributable failure in both places at once.
121
+ *
122
+ * `errors` stays the complete list and `violations` the attributable subset, so
123
+ * a consumer reading only `errors` sees exactly what it saw before this
124
+ * existed. Written through one call because two arrays maintained separately is
125
+ * how the message in one comes to differ from the message in the other.
126
+ */
127
+ export declare function violate(target: {
128
+ errors: string[];
129
+ violations: RuleViolation[];
130
+ }, constraintId: RuleConstraintId, message: string): void;
131
+ /** Constraints for one engine. */
132
+ export declare function constraintsFor(engine: EngineName): readonly RuleConstraint[];
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The rule layout table — the single description of what a rule is made of and
3
+ * where its files go, for every engine.
4
+ *
5
+ * SPLIT OUT OF `engines.ts` SO IT CAN BE PUBLISHED. This module holds data and
6
+ * nothing else: no filesystem, no network, no command tree. `engines.ts` keeps
7
+ * the helpers that join these values to a `cwd`, because those import
8
+ * `node:fs/promises` and a Worker cannot load them.
9
+ *
10
+ * The split exists because the Cloud Generator builds rule payloads against
11
+ * this table. While it lived only in prose, the same layout was described in
12
+ * seven stale code comments and in `cli-runtime-rule-execution`'s own spec text,
13
+ * all naming a path two migrations had already moved — and one of those stale
14
+ * comments put the wrong layout into a cross-team design document. A shape
15
+ * described in prose drifts in every place it is described, including the one
16
+ * that is supposed to be authoritative. Published as data, it cannot.
17
+ *
18
+ * Re-exported for consumers as `@taskless/cli/layout` (see `src/layout/`).
19
+ */
20
+ /**
21
+ * Engines this CLI knows. The directory name under `.taskless/rules/` **is**
22
+ * the engine: dispatch reads the path and never parses a rule file to decide
23
+ * who owns it.
24
+ */
25
+ export declare const ENGINES: readonly ["sg", "vale", "runtime"];
26
+ export type EngineName = (typeof ENGINES)[number];
27
+ /**
28
+ * How a rule's fixtures group into cases.
29
+ *
30
+ * Stated here rather than left implicit in each engine's fixture reader, which
31
+ * is where it lived: `runtime/fixtures.ts` rejects a loose file because a case
32
+ * is a directory, `vale/verify.ts` rejects a nested directory because a case is
33
+ * a document, and `verify.ts` counts ast-grep's own `valid:`/`invalid:` keys.
34
+ * The fact was recoverable only by reading three rejections, so every consumer
35
+ * transcribed it — which is what published this table in the first place.
36
+ *
37
+ * Each value is pinned beside the rejection that implements it, rather than in
38
+ * a test of its own: `runtime-fixtures.test.ts` asserts `case-directories`
39
+ * where it proves a loose file is refused, `vale-verify.test.ts` asserts
40
+ * `case-documents` where it proves a nested directory is refused, and
41
+ * `reference.test.ts` asserts `ast-grep-test` publishes no cases. A reader
42
+ * changing a reader's mind about its layout meets the declaration in the same
43
+ * test, which a separate file would not have achieved.
44
+ */
45
+ export type FixtureLayout =
46
+ /** A case is a directory under `pass/` or `fail/`, handed to the check as its root. */
47
+ "case-directories"
48
+ /** A case is one document under `pass/` or `fail/`. */
49
+ | "case-documents"
50
+ /** Not a directory layout: `valid:`/`invalid:` keys inside one ast-grep test file. */
51
+ | "ast-grep-test";
52
+ /** How a rule reaches execution, or `null` when this CLI has no executor yet. */
53
+ export type EngineExecutor = "ast-grep" | "vale-runner" | "runtime-harness" | null;
54
+ export interface EngineLayout {
55
+ engine: EngineName;
56
+ /**
57
+ * The file inside a rule directory that *is* the rule, as a function of the
58
+ * rule id. `sg` and `vale` name it after the rule; `runtime` always calls it
59
+ * `check.ts`, because the rule is a program rather than a document.
60
+ */
61
+ ruleFile: (ruleId: string) => string;
62
+ /**
63
+ * The engine's per-rule config file, or `undefined` where the engine has
64
+ * nothing to put in one.
65
+ *
66
+ * Only Vale has one, and not for symmetry: Vale cannot express a rule's scope
67
+ * inside the style file — measured, it rejects unknown keys with `E201` — so
68
+ * scope needs somewhere else to live. ast-grep carries `files`/`ignores`
69
+ * inside the rule itself, so an `sg` per-rule config would be a file every
70
+ * author creates, no author fills, and every reader learns to ignore.
71
+ */
72
+ ruleConfigFile: string | undefined;
73
+ /** Subdirectory holding ast-grep capture rules, for engines that use them. */
74
+ capturesDirectory: string | undefined;
75
+ /** How this engine's fixtures group into cases. See {@link FixtureLayout}. */
76
+ fixtureLayout: FixtureLayout;
77
+ executor: EngineExecutor;
78
+ }
79
+ /**
80
+ * Everything defining a rule lives in one directory,
81
+ * `.taskless/rules/<engine>/<id>/`, the same shape for every engine. A rule is
82
+ * therefore one path — which is what lets `verify` and `test` take a path
83
+ * instead of an id, and what makes deleting a rule an `rm -rf` of one thing.
84
+ */
85
+ export declare const RULES_DIRECTORY = "rules";
86
+ /**
87
+ * The directory the whole tree hangs off, relative to the project root.
88
+ *
89
+ * Here rather than beside its callers because the conformance corpus publishes
90
+ * `.taskless/rules/<engine>/<id>/` as a path a consumer can act on, and a
91
+ * published path needs one value to be generated from.
92
+ *
93
+ * It is not yet the only copy. The literal appears in about fifteen places
94
+ * behind four constants that do not know about each other — `CANONICAL_DIR` in
95
+ * `install/install.ts` and `install/canonical.ts`, `TASKLESS_DIR` in
96
+ * `install/state.ts`, `TASKLESS_DIRECTORY` in `rules/scan.ts` and
97
+ * `rules/vale/formats.ts`. `rulesRoot` reads this one, so what the corpus
98
+ * publishes is what the rule commands resolve. Converging the rest is a
99
+ * tidy-up, and doing it inside a contract change would hide the contract
100
+ * change inside a rename.
101
+ */
102
+ export declare const TASKLESS_DIRECTORY = ".taskless";
103
+ /**
104
+ * A rule's tests, relative to its rule directory. **The dot is load-bearing.**
105
+ *
106
+ * ast-grep's `ruleDirs` recurses and parses every `.yml` beneath it as a rule,
107
+ * so a plain `tests/` directory inside a rule directory fails the entire scan
108
+ * with `Fail to parse yaml as RuleConfig: missing field 'language'`. Measured
109
+ * against ast-grep 0.41.0: `tests/` and `__tests__/` both hard-fail,
110
+ * a dot-directory is skipped by rule discovery, and `sg test` still reads it
111
+ * when `testDir` names it.
112
+ *
113
+ * That is undocumented behavior, and three things make depending on it
114
+ * acceptable. The failure is loud — a parse error naming the file, never a test
115
+ * silently reinterpreted as a rule. `ast-grep-vendor-contract.test.ts` pins it
116
+ * alongside the rest of ast-grep's observed behavior, so it is
117
+ * checked on every run rather than remembered. And the binary is pinned to an
118
+ * exact version, so it cannot change without a deliberate bump, which is
119
+ * exactly where that test fires.
120
+ *
121
+ * If it ever does break, the recorded fallback is to materialize a rules-only
122
+ * tree for ast-grep and point `ruleDirs` at that (design D2).
123
+ */
124
+ export declare const RULE_TESTS_DIRECTORY = ".tests";
125
+ export declare const ENGINE_LAYOUTS: {
126
+ sg: {
127
+ engine: "sg";
128
+ ruleFile: (ruleId: string) => string;
129
+ ruleConfigFile: undefined;
130
+ capturesDirectory: undefined;
131
+ fixtureLayout: "ast-grep-test";
132
+ executor: "ast-grep";
133
+ };
134
+ vale: {
135
+ engine: "vale";
136
+ ruleFile: (ruleId: string) => string;
137
+ ruleConfigFile: string;
138
+ capturesDirectory: undefined;
139
+ fixtureLayout: "case-documents";
140
+ executor: "vale-runner";
141
+ };
142
+ runtime: {
143
+ engine: "runtime";
144
+ ruleFile: () => string;
145
+ ruleConfigFile: undefined;
146
+ capturesDirectory: string;
147
+ fixtureLayout: "case-directories";
148
+ executor: "runtime-harness";
149
+ };
150
+ };
151
+ /** Whether `value` names an engine this CLI knows. */
152
+ export declare function isKnownEngine(value: string): value is EngineName;