Skip to content

fix(validate): notice has/inside without stopBy, like follows/precedes - #691

Merged
theCodeDrift merged 2 commits into
mainfrom
fix/validate-has-inside-stopby
Nov 12, 2026
Merged

theCodeDrift merged 2 commits into
mainfrom
fix/validate-has-inside-stopby

Conversation

@theCodeDrift

@theCodeDrift theCodeDrift commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Stack (root → tip):

Stacked on #682.

Problem

has and inside default to stopBy: neighbor: has checks only direct children and inside only the direct parent. An exclusion such as not: { inside: { kind: try_statement } } silently stops excluding anything nested deeper, and a fixture written from the same assumption passes. findSgTraps noticed this for follows/precedes but not for has/inside.

Change

  • findSgTraps (packages/cli/src/rules/sg-traps.ts) gets descentTraps: a notice for each has/inside with no stopBy, naming the relation, the one level it checks, and stopBy: end. Under a not, the notice says the exclusion misses anything deeper. Still a notice, never an error.
  • A relation that names a field is left alone, since a field already pins one parent-child link (this keeps the recipe's has: { field: function, ... } idioms quiet). stopBy: neighbor silences the notice where one level is meant.
  • create-sg-rule: a Quirks table row and section beside follows/precedes, the step 5 checklist mentions has/inside, and the await_expression workarounds now say stopBy: neighbor so the recipe's own fix draws no notice.
  • Vendor contract pins both quoted behaviors against the shipped ast-grep (inside misses a try two levels up; has misses a grandchild; both fixed by stopBy: end).
  • The repo's own prefer-node-process rule drew five notices for deliberate direct-child relations; they now say stopBy: neighbor (no behavior change, verify passes).
  • Standing spec cli-rule-validation lists the new trap with a scenario.
  • Changeset: extends .changeset/sg-regex-needs-kind-schema.md from fix(validate): state sg-regex-needs-kind in the ast-grep rule schema #682 (one changeset per stack).

Closes #676

Built on top of #682

Closes #671

What changed

  • The pairing lives in the schema. astGrepRuleJsonSchema (in src/schemas/ast-grep-rule.ts) layers if: {required: [regex]}, then: {$ref: TasklessPinsKind} onto both rule definitions in ast-grep's vendored schema (SerializableRule, Relation). The generated file is untouched, so upgrades keep working, and the layering throws if a vendor bump renames either definition. getSchemaPayload() now returns the augmented schema.
  • What anchors a regex (TasklessPinsKind): a sibling kind; a sibling any whose every branch pins a kind (recursively); or an all with at least one branch that pins one. A kind inside not or a relation does not count.
  • zod check. z.fromJSONSchema throws on if/then, so astGrepRuleSchema adds a check that walks the data against the same JSON Schema. That gives the same paths by construction, including positions the old walker missed (stopBy, nthChild.ofRule, utility arguments, fix.expandStart/expandEnd, rewriters). It is built with when: () => true because a plain .superRefine is skipped when another field already failed. Issues carry params.constraintId, so validate attributes sg-regex-needs-kind from the schema layer. The old Layer 2 walker is gone.
  • Message names the fix, the sibling-any option, and how to find the kind (--debug-query=ast, and what a constraint's metavariable captures).
  • Docs. The constraint summary/rationale and the create-sg-rule recipe (topic v15) say "every regex, at any depth". There is a new Quirks row and section, and assets/reference.json is regenerated.

Tests

  • New test/ast-grep-rule-schema.test.ts: constraints, utils, not, nested has/inside, precedes/follows, any/all items, the rarer positions, and the sibling-any cases (accepted; refused with an unpinned or empty branch). Also all, nested pins, zod attribution, the issue still being reported alongside an unrelated schema failure, and the JSON Schema augmentation leaving everything else in the upstream schema unchanged.
  • Vendor contract (ast-grep 0.51.1) pins the recipe's claims: a bare top-level regex is an engine config error, a bare regex in a sub-rule runs, the sibling-any constraint example works, and $OBJ.$M() captures a property_identifier.
  • I also checked the augmented JSON Schema with Ajv (2020-12), outside the repo, against 11 cases. The paths it flags matched findRegexWithoutKind exactly. Ajv is not added as a dependency.

Notes for review

  • Behavior change: the refusal moved from the requirements layer to the schema layer, so when it fires, the engine's own single-rule parse (which only runs on a schema pass) is skipped for that round.
  • Nothing ships this schema to editors today (no $schema comment and no package export). The schema now states the pairing, but how an editor picks it up is a separate decision.

@theCodeDrift
theCodeDrift added this pull request to stack #692 November 12, 2026 02:38
@theCodeDrift theCodeDrift changed the title fix/validate has inside stopby fix(validate): notice has/inside without stopBy, like follows/precedes Nov 12, 2026
@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @theCodeDrift's task in 3m 25s —— View job


Review: fix/validate-has-inside-stopby

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains only the original @claude /review trigger comment (no review threads, reviews, or inline comments), so this is the first review of this PR.

  • Read .prior-review.json
  • Fetched PR diff and metadata via gh pr diff / gh pr view
  • Read packages/cli/src/rules/sg-traps.ts in full for context
  • Reviewed all 7 changed files
  • Verified semantics against actual AST shapes (vendor-contract tests)

Assessment

No correctness, security, or test-adequacy issues found. This is a clean, well-scoped PR.

descentTraps (packages/cli/src/rules/sg-traps.ts:130-172)

  • Traced the walk/path-building logic carefully: the negated detection (/(^|\.)not(\.|$)/.test(path)) is correctly bounded by ./start and ./end, so it won't false-positive on a kind/util name that merely contains the substring "not" (e.g. annotation, cannot) — the boundary anchors prevent that.
  • The field exemption ("field" in value) matches the stated rationale: a relation that names field already pins exactly one parent-child link, which is what the default stopBy: neighbor already checks, so no notice is needed. Verified this holds symmetrically for both has and inside.
  • Confirmed against the new vendor-contract cases (ast-grep-vendor-contract.test.ts:1316-1348) that the claimed AST shapes are real: fetch() inside a try sits two parent-levels up (expression_statement → statement_block → try_statement), so the default stopBy: neighbor on inside genuinely fails to exclude it, and stopBy: end fixes it; same for has missing a grandchild x under arguments. These are exactly the behaviors the doc/spec describe.
  • .taskless/rules/sg/prefer-node-process/prefer-node-process.yml: checked all 5 added stopBy: neighbor annotations against the rule structure — each is inserted on a has/inside that previously lacked both stopBy and field, and stopBy: neighbor is the pre-existing default, so this is a pure annotation with no behavior change, as the PR claims. No relation was missed and none was double-annotated.
  • sg-traps.test.ts and ast-grep-vendor-contract.test.ts: the two pre-existing tests that had to gain an explicit stopBy (the $$$-beside-comma test and the await_expression recipe tests) were the right ones to touch — they exercise has/inside shapes that would otherwise trip the new notice and change notices.length expectations unrelated to what those tests are checking.
  • Docs (create-sg-rule.md) and spec (openspec/specs/cli-rule-validation/spec.md) additions match the implementation precisely, including the field-exemption carve-out and the not-specific wording.

One trivial wording nit, not worth blocking on: create-sg-rule.md:466-467 says the try_statement "sits several levels further up" from the fetch() call's parent — it's actually exactly two levels (expression_statement → statement_block → try_statement). Accurate in spirit, just slightly loose on the count.

No inline comments filed — nothing rose to the level of a concrete, actionable issue.

@theCodeDrift
theCodeDrift force-pushed the fix/validate-has-inside-stopby branch from 9a365c1 to e83a113 Compare November 12, 2026 02:52
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @github-actions — "Claude finished @theCodeDrift's task in 3m 25s"…
#691 (comment)

Took the wording nit: create-sg-rule.md now says the try_statement sits two levels above the expression_statement, past the statement_block (236cf91).

— AI Coding Agent

@theCodeDrift
theCodeDrift force-pushed the fix/validate-has-inside-stopby branch from 236cf91 to 8b228d8 Compare November 12, 2026 04:03
Base automatically changed from fix/sg-regex-needs-kind-schema to main November 12, 2026 04:36
`has` and `inside` default to `stopBy: neighbor`, so `has` checks only
direct children and `inside` only the direct parent. An exclusion such
as `not: { inside: { kind: try_statement } }` silently stops excluding
anything nested deeper, and a fixture written from the same assumption
passes. findSgTraps already noticed this shape for `follows`/`precedes`
but not for these two.

descentTraps now emits a notice for each `has`/`inside` that declares no
`stopBy`, saying which level it checks and, under a `not`, that the
exclusion misses anything deeper. A relation that names a `field` is
left alone, since a field already pins one parent-child link, and
`stopBy: neighbor` silences the notice where one level is meant.

The create-sg-rule recipe gets a Quirks row and section (pinned by new
vendor-contract cases), its await_expression workarounds now say
`stopBy: neighbor`, and the repo's own prefer-node-process rule states
its five deliberate direct-child relations the same way. The standing
cli-rule-validation spec lists the new trap with a scenario.

Closes #676
@theCodeDrift
theCodeDrift force-pushed the fix/validate-has-inside-stopby branch from 8b228d8 to 2c7029c Compare November 12, 2026 04:38
@theCodeDrift
theCodeDrift merged commit 3f5e1eb into main Nov 12, 2026
4 checks passed
@theCodeDrift
theCodeDrift deleted the fix/validate-has-inside-stopby branch November 12, 2026 04:42
theCodeDrift added a commit that referenced this pull request Nov 12, 2026
Rebasing onto main brought in text from #682, #683, #686 and #691 that
pushed three outputs past 8 KiB:

- create-sg-rule outline: the `taskless ast` guidance moves to an
  on-demand Reading kinds section, step 4 points at it, and step 5 drops
  its quirk enumeration (the Quirks table lists them).
- create-sg-rule pattern-quirks: the relational entries (follows/precedes,
  has/inside, not) move to a Relational quirks section; validate-rule
  names it beside the other two.
- route outline: #686's note on recipes that limit questions moves to an
  on-demand Limits on asking section, with a one-line pointer in
  Preconditions.
theCodeDrift added a commit that referenced this pull request Nov 12, 2026
Rebasing onto main brought in text from #682, #683, #686 and #691 that
pushed three outputs past 8 KiB:

- create-sg-rule outline: the `taskless ast` guidance moves to an
  on-demand Reading kinds section, step 4 points at it, and step 5 drops
  its quirk enumeration (the Quirks table lists them).
- create-sg-rule pattern-quirks: the relational entries (follows/precedes,
  has/inside, not) move to a Relational quirks section; validate-rule
  names it beside the other two.
- route outline: #686's note on recipes that limit questions moves to an
  on-demand Limits on asking section, with a one-line pointer in
  Preconditions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant