Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/sg-regex-needs-kind-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`sg-regex-needs-kind` now lives in the ast-grep rule schema. The schema the CLI publishes states the pairing as `if`/`then` on every rule definition, and `validate` reports it from the schema layer with the same paths, so a schema-aware tool flags exactly what `validate` refuses. Every `regex`, at any depth, is covered: `rule`, `constraints`, `utils`, every `any`/`all`/`not`/`has`/`inside`/`precedes`/`follows` sub-rule, and the rarer positions (`stopBy`, `nthChild.ofRule`, utility arguments, `fix.expandStart`/`expandEnd`, rewriters). A sibling `any:` whose every branch sets a `kind` now anchors the regex, so a constraint no longer has to be split into one branch per kind, and an `all:` with one branch that sets a `kind` counts too. The message says how to find the kind, and it is reported even when another field also fails the schema. The `create-sg-rule` recipe (topic v15) lists the rule under Quirks.
4 changes: 2 additions & 2 deletions packages/cli/assets/reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
"id": "sg-regex-needs-kind",
"engine": "sg",
"enforcedBy": "validate",
"summary": "A `regex` needs a sibling `kind`, in `rule`, `constraints` and `utils`.",
"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."
"summary": "Every `regex`, at any depth, needs a `kind` beside it: a sibling `kind`, or a sibling `any` whose every branch sets one. This covers `rule`, `constraints`, `utils` and every sub-rule beneath them (`any`, `all`, `not`, `has`, `inside`, `precedes`, `follows`).",
"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 refuses it only at the top of `rule` and runs it in any sub-rule, so beyond that one spot the engine is not the thing that will tell you. The pairing is part of the rule schema Taskless publishes (`if`/`then` on every rule definition), so a schema-aware editor flags the same paths `validate` does."
},
{
"id": "sg-language-accepted",
Expand Down
52 changes: 46 additions & 6 deletions packages/cli/src/agent/create-sg-rule.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v14)
# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v15)

%(ONE_COMMAND_PER_CALL)s

Expand Down Expand Up @@ -163,8 +163,9 @@ whole rule.
| a case didn't behave as expected | fix the rule pattern OR the test case |
| the rule's scope excluded a case | move the fixture to a path `files:` admits |

A `regex` without an accompanying `kind` fails validation, the two
always travel together.
Every `regex`, at any depth, needs a `kind` beside it, or
validation fails. That includes `constraints`, `utils`, and every
`not`, `has`, `inside`, `any` and `all` sub-rule; see **Quirks**.

Pass a directory above a rule and every rule beneath it is checked,
one entry per rule in the report. `.taskless/rules/sg` covers every
Expand All @@ -188,9 +189,9 @@ whole rule.

## Quirks

Each of these comes from a grammar or from ast-grep itself, so a rule
written carefully from the upstream reference still hits them. They
fail in one of two ways:
Each of these, except the first, comes from a grammar or from ast-grep
itself, so a rule written carefully from the upstream reference still
hits them. Those fail in one of two ways:

- **Config error.** The scan exits 8 and names the problem. Loud, but
it aborts config parsing, so every other `sg` rule in the project
Expand All @@ -208,6 +209,7 @@ sometimes what the author meant. Read it and decide.

| quirk | symptom | `validate` reports it |
|------------------------------------------------|-------------------------|-----------------------------|
| a `regex` without a `kind`, at any depth | refused by `validate` | yes |
| `language:` copied from `detect --json` | config error, or notice | yes |
| `TypeScript` declared over `.tsx` files | silent | only if `files:` names .tsx |
| an inline construct named in a `Markdown` rule | config error, or silent | the `kind:` form only |
Expand All @@ -217,6 +219,44 @@ sometimes what the author meant. Read it and decide.
| `follows` or `precedes` without `stopBy: end` | silent | as a notice |
| a `not` containing a bare metavariable | silent | no |

### Every `regex`, at any depth, needs a `kind`

This one is a Taskless rule, wider than ast-grep's own check. ast-grep
refuses a bare `regex` only at the top of `rule` (a config error); in
any sub-rule it runs it against the text of every node it reaches,
which is ambiguous and slow. `validate` refuses a `regex` with no
`kind` beside it anywhere it appears: the top of `rule`,
`constraints.<VAR>`, `utils`, `not`, `has`/`inside` (however deeply
nested), `precedes`/`follows`, and each `any`/`all` branch.

A sibling `any:` whose every branch sets a `kind` counts, so a
metavariable that may capture one of several kinds keeps a single
regex. A callee is an `identifier` in `rpc()` and a
`member_expression` in `client.rpc()`:

```yaml
rule:
pattern: "$CALLEE()"
constraints:
CALLEE:
any:
- kind: identifier
- kind: member_expression
regex: "(^|\\.)rpc$"
```

An `all:` with one branch that sets a `kind` counts too. A `kind`
inside `not`, `has` or another relation does not, because it describes
a different node.

To find the kind, run ast-grep with `--debug-query=ast` on a sample of
the code and take the named node the regex should match. Under
`constraints`, that is the node the metavariable captures, which is
not always the obvious one: the method `$M` captures in `$OBJ.$M()` is
a `property_identifier` in TypeScript, not an `identifier`. Read it off
the dump rather than guessing, and if the metavariable can capture
several kinds, list them in a sibling `any:` as above.

### `language:` copied from `detect --json`

`detect --json` reports the *repository's* languages, in a different
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/rules/constraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ export const RULE_CONSTRAINTS = [
engine: "sg",
enforcedBy: "validate",
summary:
"A `regex` needs a sibling `kind`, in `rule`, `constraints` and `utils`.",
"Every `regex`, at any depth, needs a `kind` beside it: a sibling `kind`, or a sibling `any` whose every branch sets one. This covers `rule`, `constraints`, `utils` and every sub-rule beneath them (`any`, `all`, `not`, `has`, `inside`, `precedes`, `follows`).",
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.",
"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 refuses it only at the top of `rule` and runs it in any sub-rule, so beyond that one spot the engine is not the thing that will tell you. The pairing is part of the rule schema Taskless publishes (`if`/`then` on every rule definition), so a schema-aware editor flags the same paths `validate` does.",
},
{
id: "sg-language-accepted",
Expand Down
61 changes: 23 additions & 38 deletions packages/cli/src/rules/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { readFile } from "node:fs/promises";
import { parse } from "yaml";

import {
astGrepRuleJsonSchema,
astGrepRuleSchema,
REGEX_NEEDS_KIND,
TASKLESS_REQUIRED_FIELDS,
findRegexWithoutKind,
} from "../schemas/ast-grep-rule";
import { pathPrefixed, schemaLayer } from "../schemas/layer";
import {
Expand All @@ -28,7 +29,6 @@ import {
import { ensureTasklessDirectory } from "../filesystem/directory";
import { ruleFilePath } from "./engines";
import { RULE_TESTS_DIRECTORY, RULES_DIRECTORY } from "./layout";
import astGrepJsonSchema from "../generated/ast-grep-rule-schema.json";
import { RULE_EXAMPLES } from "./verify-examples";
import { violate, type RuleViolation } from "./constraints";
import { isValidRuleId } from "./validate-id";
Expand Down Expand Up @@ -105,14 +105,14 @@ export interface VerifyResult {

export function getSchemaPayload(): Record<string, unknown> {
return {
astGrepSchema: astGrepJsonSchema,
astGrepSchema: astGrepRuleJsonSchema,
tasklessRequirements: {
requiredFields: [...TASKLESS_REQUIRED_FIELDS],
rules: [
{
name: "regex-requires-kind",
description:
"Rules using `regex` in any position must also specify `kind` at the same level. Without kind, regex matches against node text which is ambiguous and slow.",
"Every `regex`, at any depth (`rule`, `constraints`, `utils`, and every sub-rule beneath them), needs a `kind` beside it: a sibling `kind`, or a sibling `any` whose every branch sets `kind`. `astGrepSchema` states this as `if`/`then` on its rule definitions. Without kind, regex matches against node text which is ambiguous and slow.",
},
],
},
Expand All @@ -127,7 +127,24 @@ function validateSchema(ruleData: unknown): LayerResult {
// way whichever engine wrote it. `pathPrefixed` is the half that differs:
// ast-grep's messages come from an upstream JSON Schema and are generic
// ("expected string"), so the path is what makes them actionable.
return schemaLayer(astGrepRuleSchema.safeParse(ruleData), pathPrefixed);
const result = astGrepRuleSchema.safeParse(ruleData);
const layer = schemaLayer(result, pathPrefixed);
// The regex/kind pairing lives in the schema (see `astGrepRuleJsonSchema`),
// and its issues say which constraint they are, so they are attributed
// here rather than re-derived from their wording.
const violations: RuleViolation[] = [];
for (const issue of result.error?.issues ?? []) {
if (
issue.code === "custom" &&
issue.params?.constraintId === REGEX_NEEDS_KIND
) {
violations.push({
constraintId: REGEX_NEEDS_KIND,
message: pathPrefixed(issue),
});
}
}
return { ...layer, violations };
}

/**
Expand Down Expand Up @@ -309,38 +326,6 @@ async function validateRequirements(
}
}

// Check regex-requires-kind in rule and other top-level rule containers
for (const key of ["rule", "constraints", "utils"]) {
const container = ruleData[key];
if (
container &&
typeof container === "object" &&
!Array.isArray(container)
) {
if (key === "rule") {
for (const message of findRegexWithoutKind(
container as Record<string, unknown>
)) {
violate(collected, "sg-regex-needs-kind", message);
}
} else {
// constraints/utils are Record<string, RuleObject>
for (const [name, value] of Object.entries(
container as Record<string, unknown>
)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const message of findRegexWithoutKind(
value as Record<string, unknown>,
`${key}.${name}`
)) {
violate(collected, "sg-regex-needs-kind", message);
}
}
}
}
}
}

// `strictness:` at the top level is accepted by ast-grep and ignored, so the
// pattern keeps narrowing exactly as before. Inside `rule:` the same key is a
// schema error; up here only this check can say so.
Expand Down Expand Up @@ -601,7 +586,7 @@ export async function verifyRule(
const schemaResult: SchemaLayerResult = {
valid: schemaErrors.length === 0,
errors: schemaErrors,
violations: language.violations,
violations: [...(parsed.violations ?? []), ...language.violations],
notices: [...language.notices, ...findSgTraps(ruleRecord)],
};

Expand Down
Loading
Loading