Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/verify-sg-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@taskless/cli": patch
---

Validate an sg rule's `language:` field in `verify`, instead of leaving it to
ast-grep at `check` time.

Nothing local had an opinion on the field. The vendored rule schema types it as
a bare string with no enum, so `verify` returned `ok: true` for any spelling and
the binary was the first thing to object — in the two ways it objects, both of
them late:

- A name ast-grep does not recognize fails `SgLang` deserialization, which
aborts parsing of the single config Taskless assembles per run. One typo takes
every _other_ sg rule down with it. `verify` now fails that rule by name,
prints the accepted spellings, and suggests the obvious canonical one where
there is one (`C#` → `CSharp`).
- A recognized name pointing at the wrong parser reports nothing and reads as a
clean codebase. `Tsx` and `TypeScript` are two parsers, not aliases, so a
`TypeScript` rule scoped to `**/*.tsx` matches nothing and exits zero.
`verify` fails that rule, and notices the half-dead case where a `{ts,tsx}`
glob reaches both.

Case variants and ast-grep's extension aliases are accepted rather than
rejected, since ast-grep accepts them itself: `typescript`, `TYPESCRIPT` and
`ts` all reach TypeScript. They get a notice naming the canonical spelling, so
rules already written the lowercase way — including the ones in this
repository — keep passing.

The `files:` scan reads both shapes ast-grep allows for a glob entry, the plain
string and the `{ glob, caseInsensitive }` object, so the wrong-parser check is
not silently skipped for rules written the second way.
117 changes: 108 additions & 9 deletions packages/cli/src/rules/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
* render time:
*
* - `src/generated/ast-grep-rule-schema.json` types `$defs.Language` as a bare
* string with no enum, and `verify` never validates a rule's `language`, so
* any spelling passes our own checks and fails only inside ast-grep.
* string with no enum — its only hint is an `example` reading `"typescript"`,
* which is not even the canonical spelling — so the vendored schema cannot
* answer the question. `verify` answers it from the constants below instead
* (see `validateLanguage` in `verify.ts`).
* - `detect --json` reports the *repository's* languages in a different
* vocabulary — `C++` where ast-grep says `Cpp` — and says nothing about what
* an engine can parse.
Expand Down Expand Up @@ -46,13 +48,14 @@ export const AST_GREP_VERSION = "0.41.0";
*
* SPELLINGS ARE ast-grep's, NOT ours and not `detect`'s. `Cpp`, `CSharp`,
* `JavaScript`, `Tsx` — a rule's `language:` field is handed to ast-grep
* unchanged and `verify` does not check it, so the binary is the first thing
* with an opinion. MEASURED at 0.41.0: it accepts some off-list aliases
* (`C++` and `cpp` both resolve to Cpp), so an off-list spelling is not
* reliably an error. The two real failures are a name ast-grep does not know
* at all (`C#`), which aborts config parsing so every rule goes unreported,
* and a valid name for the wrong parser (`TypeScript` over `.tsx`), which
* reports nothing and reads as a clean codebase. Neither is caught locally.
* unchanged, so the binary has the final opinion. MEASURED at 0.41.0: it
* accepts more than this list — case variants and a fixed set of extension
* aliases, both enumerated in {@link AST_GREP_LANGUAGE_ALIASES} — so an
* off-list spelling is not on its own an error. The two real failures are a
* name ast-grep does not know at all (`C#`), which aborts config parsing so
* every rule goes unreported, and a valid name for the wrong parser
* (`TypeScript` over `.tsx`), which reports nothing and reads as a clean
* codebase. `verify` catches both; see `verify.ts`.
*
* Pinned by set-equality against the binary in
* `test/ast-grep-vendor-contract.test.ts`, so a version bump that adds or drops
Expand Down Expand Up @@ -88,6 +91,102 @@ export const AST_GREP_LANGUAGES = [
"Yaml",
] as const;

/** One of the spellings {@link AST_GREP_LANGUAGES} lists, canonically cased. */
export type AstGrepLanguage = (typeof AST_GREP_LANGUAGES)[number];

/**
* The spellings ast-grep also accepts that are not on the canonical list,
* mapped to the language each resolves to.
*
* Keys are lowercase because ast-grep's own matching is case-insensitive:
* `TYPESCRIPT`, `Cs` and `GOLANG` all resolve at 0.41.0. That makes the whole
* accepted vocabulary "the canonical list plus these, compared lowercased",
* which is what {@link resolveAstGrepLanguage} implements.
*
* A RULE'S `language:` FIELD AND `sg run --lang` DO NOT SHARE A VOCABULARY.
* Measured at 0.41.0: `--lang C++` is rejected outright while a rule declaring
* `language: C++` parses fine. Every value here was probed through a real
* config, because that is the only thing a rule file is ever fed to.
*
* THIS IS THE ONE LIST HERE THE BINARY CANNOT BE ASKED TO ENUMERATE. `sg run
* -h` prints the canonical list, so `AST_GREP_LANGUAGES` above is checked by
* set-equality against it; nothing prints the aliases. Each entry is instead
* pinned by *probing*, in the "language aliases" suite of
* `test/ast-grep-vendor-contract.test.ts`: every key is fed to the binary in a
* config and the resolution is read back out of the scan stream's own
* `language` field — ast-grep reports the canonical name it settled on, so the
* mapping is the binary's answer rather than ours. The same suite feeds a
* sweep of near-misses (`h`, `mjs`, `sh`, `tf`, `csx`) and asserts they are
* rejected, so a bump that ADDS an alias fails there too.
*
* Whitespace is not folded, deliberately: `language: "ts "` is rejected by the
* binary, so accepting it here would pass a rule that cannot run.
*/
export const AST_GREP_LANGUAGE_ALIASES: Readonly<
Record<string, AstGrepLanguage>
> = {
"c++": "Cpp",
cc: "Cpp",
cs: "CSharp",
cxx: "Cpp",
ex: "Elixir",
golang: "Go",
hs: "Haskell",
js: "JavaScript",
jsx: "JavaScript",
kt: "Kotlin",
py: "Python",
rb: "Ruby",
rs: "Rust",
sol: "Solidity",
ts: "TypeScript",
yml: "Yaml",
};

/** Every canonical name, keyed by its own lowercase spelling. */
const CANONICAL_BY_LOWERCASE = new Map<string, AstGrepLanguage>(
AST_GREP_LANGUAGES.map((name) => [name.toLowerCase(), name])
);

/**
* The language ast-grep would parse `spelling` as, or `undefined` if it would
* reject the config outright.
*
* `undefined` is the fatal case, not a stylistic one: an unrecognized name
* fails `SgLang` deserialization, which aborts parsing of the single config
* Taskless assembles for the run — so every *other* sg rule goes unreported
* with it.
*/
export function resolveAstGrepLanguage(
spelling: string
): AstGrepLanguage | undefined {
// NOT trimmed. Measured at 0.41.0, ast-grep rejects `"ts "` — folding the
// whitespace here would call a rule valid that the binary refuses to load.
const key = spelling.toLowerCase();
return CANONICAL_BY_LOWERCASE.get(key) ?? AST_GREP_LANGUAGE_ALIASES[key];
}

/**
* The `.ts` / `.tsx` split — the one pair of ast-grep languages that share a
* family and read disjoint file extensions.
*
* MEASURED at 0.41.0: a `TypeScript` rule over a `.tsx` tree exits zero having
* matched nothing, and a `Tsx` rule scans `.tsx` only. That is the quiet
* failure of the two, because "no findings" is exactly what a clean codebase
* looks like. Pinned by "treats Tsx and TypeScript as different parsers, not
* aliases" in `test/ast-grep-vendor-contract.test.ts`.
*
* Kept to this pair deliberately. Every other language's extensions would be a
* second vendored table with no measured backing, and the trap only exists
* where two languages look like spellings of one thing.
*/
export const AST_GREP_TSX_SPLIT: Readonly<
Partial<Record<AstGrepLanguage, string>>
> = {
TypeScript: "ts",
Tsx: "tsx",
};

/**
* The Vale release carried by the `@taskless/vale-<platform>` packages pinned
* in `packages/cli/package.json`. Their npm versions append a build stamp
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/rules/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export interface RuleVerification {
ruleId: string;
ok: boolean;
errors: string[];
/**
* Something true about the rule that does not make it invalid. An sg rule
* spelled `language: typescript` reaches the right parser and fails nothing,
* but the canonical spelling is `TypeScript` — worth saying, not worth
* failing. Surfaced even on a pass, for the same reason
* {@link RuleTestResult.notice} is.
*/
notice?: string;
}

/** What `test` concluded about one rule. */
Expand Down Expand Up @@ -65,7 +73,15 @@ async function verifySgRule(
// test layer is `test`'s business, so it is not part of the verdict here.
const errors = [...result.schema.errors, ...result.requirements.errors];
return {
verification: { engine: "sg", ruleId, ok: errors.length === 0, errors },
verification: {
engine: "sg",
ruleId,
ok: errors.length === 0,
errors,
...(result.schema.notice === undefined
? {}
: { notice: result.schema.notice }),
},
result,
};
}
Expand Down Expand Up @@ -233,6 +249,9 @@ export async function testOneRule(
ok: result.tests.valid,
errors,
ran: true,
...(verification.notice === undefined
? {}
: { notice: verification.notice }),
};
}

Expand Down
Loading