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/ast-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

New `taskless ast <file>` prints a file's named-node AST, parsed by the bundled ast-grep as the language the file's extension resolves to, so every kind it shows is one a rule's `kind:` accepts. `--range <line>[:<line>]` cuts the tree to the nodes on those lines, `--lang` overrides the language, and `--json` emits the tree as data. `create-sg-rule` now sends authors to it for kinds instead of to ast-grep's `--debug-query`, which left agents hunting for an ast-grep binary.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-11
27 changes: 27 additions & 0 deletions openspec/changes/archive/2026-11-11-ast-command/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Why

Writing an ast-grep rule means knowing the node kinds of the target language, and the CLI gave no way to see them. `create-sg-rule` told authors to read kinds off `--debug-query=ast` without saying how to run ast-grep, so agents went hunting for the binary: under `~/.npm/_npx` or `.taskless/.cache`, into `/usr/bin/sg` (shadow-utils on Linux, not ast-grep), or by pasting whole files as patterns. About 7 of 40 onboarding runs in the October to November agent survey reported it, across C#, Dart, Rust and PHP (#673).

## What Changes

- New `taskless ast <file>` prints the file's named-node AST, parsed by the bundled ast-grep as the language the file's extension resolves to. The language is asked of the binary (a `scan --inspect entity` probe against an empty config), so it is the resolution `check` gets, not a second table.
- Named nodes only, so every kind shown is one `kind:` accepts. Each node carries its field name, 1-based line and 0-based byte column range, and leaves carry their source text.
- `--lang <Language>` overrides the language, `--range <line>[:<line>]` cuts the tree to the nodes touching those lines and the nodes enclosing them, `--json` emits the tree as data. Failures under `--json` use the standard envelope: `INVALID_INPUT` for a missing, empty, unrecognized or oversized file and for a bad flag, `ENGINE_UNAVAILABLE` when there is no ast-grep, `SCAN_FAILED` when ast-grep prints no tree.
- `create-sg-rule` (topic v15) tells authors to read every `kind:` off `ast`, and stops naming `--debug-query`. The `sg-regex-needs-kind` message names `ast` as where to find a kind.

The tree comes from `ast-grep run --debug-query=ast` with the file's contents as the pattern, the only file-tree output ast-grep has. That passes the file through argv, so a file past the platform's argument limit (128 KiB per argument on Linux) is refused with a message saying so. The `--pattern` subtree filter the issue lists as optional is left out: `--range` covers the length problem without a second ast-grep invocation.

## Capabilities

### New Capabilities

- `cli-ast`: the `taskless ast` command.

### Modified Capabilities

- `cli-agent-authoring`: the ast-grep authoring recipe directs kind lookups to `taskless ast`.

## Impact

- `packages/cli/src/commands/ast.ts`, `src/rules/ast-dump.ts`, `src/schemas/ast.ts`, the command registry, `src/rules/verify.ts`, and the `create-sg-rule` recipe.
- Delivery shape: **single PR**. One read-only command, its recipe pointer, and tests, well under the review size.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## ADDED Requirements

### Requirement: The ast-grep authoring recipe sends kind lookups to the ast command

`create-sg-rule` SHALL tell the author to read every `kind:` off `taskless ast <file>` run on a file the rule targets, and SHALL name its `--range`, `--lang` and `--json` options. It SHALL NOT direct the author to run ast-grep's `--debug-query` or to locate an ast-grep binary. The `sg-regex-needs-kind` message from `validate` SHALL name the same command as where to find a kind to add.

#### Scenario: The recipe names the command

- **WHEN** an agent runs `taskless agent create-sg-rule`
- **THEN** the recipe SHALL contain an invocation of `ast <file>`
- **AND** it SHALL NOT contain `--debug-query`
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
## ADDED Requirements

### Requirement: The ast command prints a file's named-node AST

The CLI SHALL provide `taskless ast <file>`, which prints the named-node syntax tree of one file as the ast-grep bundled with the CLI parses it. It SHALL print named nodes only, so that every kind it shows is one a rule's `kind:` accepts. Each node SHALL carry its kind, the field it fills in its parent when the grammar names one, and its start and end as a 1-based line and a 0-based byte column. A node with no named children SHALL carry its source text. The command SHALL read the file and write nothing, and SHALL NOT require `.taskless/` to exist. It SHALL accept `--dir`/`-d`, against which a relative file path resolves.

#### Scenario: A file is dumped by its extension

- **WHEN** `taskless ast src/a.ts` is run on a file holding `const a = 1;`
- **THEN** the output SHALL name the language `TypeScript`
- **AND** it SHALL list `lexical_declaration`, `variable_declarator` and `name: identifier` with their positions
- **AND** it SHALL NOT list the `const` keyword as a node

#### Scenario: JSON output

- **WHEN** `taskless ast <file> --json` succeeds
- **THEN** stdout SHALL be one JSON object with `ok: true`, `file`, `language`, `errorNodes` and a nested `root` node
- **AND** `errorNodes` SHALL count the `ERROR` nodes in the whole tree, so a file that did not parse cleanly as that language is visible

### Requirement: The ast command uses check's language resolution

Without `--lang`, the command SHALL parse the file as the language the bundled ast-grep assigns its path, asked of the binary rather than of a table in the CLI, and SHALL NOT apply the `languageGlobs` of an `sgconfig.yml` found above the working directory, since `check` does not. `--lang` SHALL take any spelling a rule's `language:` accepts and parse as the language it resolves to.

#### Scenario: An extension ast-grep does not recognize

- **WHEN** `taskless ast notes.unknownext --json` is run
- **THEN** stdout SHALL be an `INVALID_INPUT` envelope that says the extension is not recognized and lists the languages `--lang` accepts
- **AND** the exit code SHALL be 1

#### Scenario: A language override

- **WHEN** `taskless ast list.txt --lang yaml` is run
- **THEN** the file SHALL be parsed as `Yaml`

### Requirement: The ast command can cut the tree to a line range

`--range <line>` or `--range <from>:<to>`, 1-based and inclusive, SHALL cut the tree to the nodes whose lines overlap the range, keeping the nodes that enclose them. A node that ends at column 0 of a line SHALL NOT count as on that line. A range that no node touches, and a malformed range, SHALL fail with `INVALID_INPUT`.

#### Scenario: One line of a file

- **WHEN** a file's third line is `foo(b);` and `taskless ast <file> --range 3` is run
- **THEN** the root SHALL keep only the `expression_statement` on line 3 among its children

### Requirement: The ast command reports what it cannot dump

The command SHALL fail with the standard `--json` error envelope and exit code 1, and SHALL NOT print a partial tree, when the file is missing, empty, a directory or not valid UTF-8, when `--lang` names no ast-grep language, when the file is too large to hand to ast-grep on this platform (each `INVALID_INPUT`), when no ast-grep binary is found (`ENGINE_UNAVAILABLE`), and when ast-grep prints no tree it can parse (`SCAN_FAILED`).

#### Scenario: A file too large for the command line

- **WHEN** `taskless ast big.js --json` is run on a file past the platform's argument limit
- **THEN** stdout SHALL be an `INVALID_INPUT` envelope saying the file is too large to dump
17 changes: 17 additions & 0 deletions openspec/changes/archive/2026-11-11-ast-command/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. Command

- [x] 1.1 Resolve a file's language by asking the bundled ast-grep (`scan --inspect entity` against an empty config)
- [x] 1.2 Dump the named-node tree through `run --debug-query=ast` and parse it, with fields, positions and leaf text
- [x] 1.3 Add `taskless ast <file>` with `--lang`, `--range`, `--json`, `-d`, and the standard error envelope
- [x] 1.4 Register `ast` in the subcommand registry

## 2. Pointers

- [x] 2.1 `create-sg-rule`: read kinds off `ast`, drop `--debug-query`, bump the topic to v15
- [x] 2.2 `sg-regex-needs-kind`: name `ast` as where to find a kind

## 3. Tests

- [x] 3.1 Parser, range filter and language-probe units
- [x] 3.2 Against the bundled binary: every language dumps in the parsed format, every language's extension resolves, `sgconfig.yml` is ignored, `$FOO` in PHP is dumped as written
- [x] 3.3 The built CLI: text and JSON output, `--range`, `--lang`, and each `INVALID_INPUT` refusal, including an oversized file
10 changes: 10 additions & 0 deletions openspec/specs/cli-agent-authoring/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,13 @@ A per-engine recipe SHALL keep only what is that engine's own, such as Vale's sc

- **WHEN** the agent writes fixtures for a rule that declares `files:` or Vale matchers
- **THEN** the shared section SHALL direct it to place each case's files at a path the scope accepts

### Requirement: The ast-grep authoring recipe sends kind lookups to the ast command

`create-sg-rule` SHALL tell the author to read every `kind:` off `taskless ast <file>` run on a file the rule targets, and SHALL name its `--range`, `--lang` and `--json` options. It SHALL NOT direct the author to run ast-grep's `--debug-query` or to locate an ast-grep binary. The `sg-regex-needs-kind` message from `validate` SHALL name the same command as where to find a kind to add.

#### Scenario: The recipe names the command

- **WHEN** an agent runs `taskless agent create-sg-rule`
- **THEN** the recipe SHALL contain an invocation of `ast <file>`
- **AND** it SHALL NOT contain `--debug-query`
57 changes: 57 additions & 0 deletions openspec/specs/cli-ast/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# cli-ast Specification

## Purpose

The `taskless ast` subcommand: a read-only dump of one file's named-node syntax tree, as the bundled ast-grep parses it, so a rule author can read real `kind:` names instead of guessing them.

## Requirements

### Requirement: The ast command prints a file's named-node AST

The CLI SHALL provide `taskless ast <file>`, which prints the named-node syntax tree of one file as the ast-grep bundled with the CLI parses it. It SHALL print named nodes only, so that every kind it shows is one a rule's `kind:` accepts. Each node SHALL carry its kind, the field it fills in its parent when the grammar names one, and its start and end as a 1-based line and a 0-based byte column. A node with no named children SHALL carry its source text. The command SHALL read the file and write nothing, and SHALL NOT require `.taskless/` to exist. It SHALL accept `--dir`/`-d`, against which a relative file path resolves.

#### Scenario: A file is dumped by its extension

- **WHEN** `taskless ast src/a.ts` is run on a file holding `const a = 1;`
- **THEN** the output SHALL name the language `TypeScript`
- **AND** it SHALL list `lexical_declaration`, `variable_declarator` and `name: identifier` with their positions
- **AND** it SHALL NOT list the `const` keyword as a node

#### Scenario: JSON output

- **WHEN** `taskless ast <file> --json` succeeds
- **THEN** stdout SHALL be one JSON object with `ok: true`, `file`, `language`, `errorNodes` and a nested `root` node
- **AND** `errorNodes` SHALL count the `ERROR` nodes in the whole tree, so a file that did not parse cleanly as that language is visible

### Requirement: The ast command uses check's language resolution

Without `--lang`, the command SHALL parse the file as the language the bundled ast-grep assigns its path, asked of the binary rather than of a table in the CLI, and SHALL NOT apply the `languageGlobs` of an `sgconfig.yml` found above the working directory, since `check` does not. `--lang` SHALL take any spelling a rule's `language:` accepts and parse as the language it resolves to.

#### Scenario: An extension ast-grep does not recognize

- **WHEN** `taskless ast notes.unknownext --json` is run
- **THEN** stdout SHALL be an `INVALID_INPUT` envelope that says the extension is not recognized and lists the languages `--lang` accepts
- **AND** the exit code SHALL be 1

#### Scenario: A language override

- **WHEN** `taskless ast list.txt --lang yaml` is run
- **THEN** the file SHALL be parsed as `Yaml`

### Requirement: The ast command can cut the tree to a line range

`--range <line>` or `--range <from>:<to>`, 1-based and inclusive, SHALL cut the tree to the nodes whose lines overlap the range, keeping the nodes that enclose them. A node that ends at column 0 of a line SHALL NOT count as on that line. A range that no node touches, and a malformed range, SHALL fail with `INVALID_INPUT`.

#### Scenario: One line of a file

- **WHEN** a file's third line is `foo(b);` and `taskless ast <file> --range 3` is run
- **THEN** the root SHALL keep only the `expression_statement` on line 3 among its children

### Requirement: The ast command reports what it cannot dump

The command SHALL fail with the standard `--json` error envelope and exit code 1, and SHALL NOT print a partial tree, when the file is missing, empty, a directory or not valid UTF-8, when `--lang` names no ast-grep language, when the file is too large to hand to ast-grep on this platform (each `INVALID_INPUT`), when no ast-grep binary is found (`ENGINE_UNAVAILABLE`), and when ast-grep prints no tree it can parse (`SCAN_FAILED`).

#### Scenario: A file too large for the command line

- **WHEN** `taskless ast big.js --json` is run on a file past the platform's argument limit
- **THEN** stdout SHALL be an `INVALID_INPUT` envelope saying the file is too large to dump
34 changes: 26 additions & 8 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 v15)
# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v16)

%(ONE_COMMAND_PER_CALL)s

Expand Down Expand Up @@ -71,6 +71,24 @@ whole rule.
Optional but useful: `note` (multi-line guidance, supports markdown),
`fix` (auto-fix pattern), `ignores` (file patterns to skip).

**Read every `kind:` off a real file.** A kind is a node name from
the language's grammar, and guessing one is how a rule ends up a
config error or matching nothing. Print the tree of a file the rule
targets:

```
%(TASKLESS_CLI)s ast <file>
%(TASKLESS_CLI)s ast <file> --range 40:60
```

It parses the file with the ast-grep this CLI ships, as the language
its extension resolves to, which is the parser `check` uses for it.
It prints named nodes only, so every kind it shows is one `kind:`
accepts. `--range <line>` or `--range <from>:<to>` keeps the nodes on
those lines and the ones enclosing them, `--lang <Language>` parses
as another language, and `--json` gives the same tree as data. There
is no need to locate an ast-grep binary yourself.

**`language` is ast-grep's vocabulary.** The vendored rule schema
types the field as a bare string with no enum, so the schema layer
accepts anything. `validate` checks it separately: an off-list spelling
Expand Down Expand Up @@ -103,7 +121,7 @@ whole rule.
assumption passes anyway. The table says which ones `validate` does
catch. Look again at:
- a `$$$` next to a comma (the separator trap),
- any `kind:` you read off a `--debug-query=cst` dump,
- any `kind:` you did not read off `%(TASKLESS_CLI)s ast`,
- a rule that keys on a call's callee, if callers write
`await f<T>(...)`,
- a `has`, `inside`, `follows` or `precedes` without `stopBy`, or
Expand Down Expand Up @@ -251,7 +269,7 @@ 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
To find the kind, run `%(TASKLESS_CLI)s ast <file>` 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
Expand Down Expand Up @@ -330,7 +348,7 @@ behaves identically, so there is no version to wait for.

### A keyword is not a valid `kind`

`--debug-query=cst` prints keywords as nodes. For a constructor
A concrete syntax tree prints keywords as nodes. For a constructor
parameter `readonly x: number` in TypeScript:

```
Expand All @@ -342,13 +360,13 @@ required_parameter

but `kind: readonly` is a config error, ``Kind `readonly` is
invalid``. `readonly` is an anonymous token, and `kind:` accepts only
named nodes. The CST dump prints both the same way, so reading it
named nodes. A CST printout shows both the same way, so reading one
cannot tell you which names are valid kinds. This holds for every
keyword, not only `readonly`.

Workaround: read kinds off `--debug-query=ast` instead, which prints
named nodes only. A name that appears there is a usable kind; one that
appears only in the CST dump is not. To match on the keyword, match
Workaround: read kinds off `%(TASKLESS_CLI)s ast <file>` instead, which
prints named nodes only. A name that appears there is a usable kind, and
a keyword never does. To match on the keyword, match
its named parent and test the parent's text. Cover every shape the
keyword appears in: after a decorator or an accessibility modifier
(`private readonly x`), and on an optional parameter (`readonly x?`),
Expand Down
Loading
Loading