Skip to content

feat(ansible): a fourth tier for YAML whose meaning is in its key names - #365

Open
bogdandragosaccesa wants to merge 1 commit into
trailhq:mainfrom
bogdandragosaccesa:ansible-tier
Open

bogdandragosaccesa wants to merge 1 commit into
trailhq:mainfrom
bogdandragosaccesa:ansible-tier

Conversation

@bogdandragosaccesa

Copy link
Copy Markdown

Adds an Ansible tier: a fourth extractor for YAML whose meaning is in its key names. No new dependencies — it reuses the tree-sitter-wasm yaml grammar already in the tree.

Why not a GENERIC_LANGS row

I tried to make this fit an existing tier first, and none of them can hold it:

  • Depth (extract.ts) and breadth (generic.ts) both ask a grammar "what definitions does this syntax declare?" YAML's answer is always "mappings and sequences". A tags.scm over tree-sitter-yaml would capture every key in every Kubernetes manifest, CI workflow and lock file in the repo and call it a definition — worse than not indexing, and it would land squarely in the coverage-honesty problem EXTENSIONS' comment already worries about.
  • Container (container.ts) does not apply either: there is no embedded language to hand off. The YAML is the program.

Ansible's structure lives in its key names — hosts: makes a play, notify: names a handler, include_tasks: names a file. So this tier uses the grammar only as a reader (key/value pairs and their exact lines) and puts the semantics in ansible.ts.

What it emits

node from
file one per Ansible file
module a role, minted at roles/<name>/tasks/main.yml
class a play (- hosts: web), signature hosts: …
function a task or handler, signature = its module (ansible.builtin.apt)
variable a vars/defaults file, a vars: block, or set_fact

Wired by contains, plus the two edges that make it a graph rather than an outline:

  • importsinclude_tasks / import_tasks / vars_files / import_playbook, emitted as a file-relative specifier so resolveImport settles it unchanged.
  • callsinclude_role / import_role / roles: (carrying kinds: ["module"] so it lands on the role) and notify: / listen: (to the handler).

No resolver changes. Everything routes through machinery that already exists.

Variable references ({{ foo }}) are deliberately not edges: resolve.ts only resolves a bare-name references edge for generic-origin nodes, so every one emitted would be built and dropped — and a name-only match across a repo-wide var namespace is the same guess #35 measured as halving call precision. Definitions are indexed; uses stay grep.

Detection

This is the risk, so it is narrow and its load-bearing half is structural: a sequence at the document root whose items are Ansible-shaped mappings. Kubernetes, Compose and Actions files are all mapping-rooted and cannot reach the accept path at all. The one mapping-rooted Ansible shape — a vars file — is admitted only from a path Ansible itself gives meaning to (group_vars/, host_vars/, a role's defaults//vars/).

Two consequences I think matter for this project specifically:

  • A declined file emits zero nodes — not an empty file node — and does not add its language to the banner. A repo full of non-Ansible YAML therefore looks exactly as it did before this tier existed. (build.ts gains a fileNodes.length guard on langs.add(label) for this; it is a no-op for the other tiers, which always emit at least a file node.)
  • Bare module keys match a curated allowlist, while an FQCN (ansible.builtin.apt) is accepted structurally. "Any lowercase key is a module" read - name: alice + description: an admin as a task. The failure mode is now a false negative — an uncommon bare module with no directive is missed — which costs coverage rather than trust in the counts.

Three bugs the verification caught, now pinned by tests

  1. Unwrapping with a recursive descendant search read mapping-rooted data files as sequences. aggregates: + a - name: list is a mapping whose value contains a sequence; a first-descendant-of-type search returns that inner sequence as if it were the document root. Eight declarative config files in my test repo were misdetected this way. unwrap() now passes through wrapper nodes only.
  2. comment is a NAMED node in tree-sitter-yaml and sits as a sibling before the content it describes. A playbook opening with a comment block has 13 comment children before its block_node, so taking the first named child concluded "not a sequence" — this rejected nearly every real playbook.
  3. Trailing-newline span inflation. A block node running to the end of its parent swallows the terminating newline, so tree-sitter reports endPosition as row N+1 column 0 and every last-in-parent play/task read one line too long. This is exactly the plausible-but-wrong file:line container.ts's header warns about, so span() trims it.

graft check parity (#236)

check.ts gets the same branch in the same order as build.ts. Its existing comment about a tier the build writes and the check cannot see reading as removed forever was the most useful thing I read while writing this — the end-to-end test asserts a clean build of an Ansible repo checks as in sync.

Verification

Measured on a 617-file Ansible-first GitOps repo (not synthetic):

  • 44 → 123 files, 335 → 1,657 nodes (1,058 tasks, 272 vars, 113 plays, 1 role).
  • 79 of 214 YAML files accepted, every one under playbooks/ or */ansible/; every decline under those paths is a Kubernetes/ArgoCD manifest that happens to live there.
  • 969 of 970 emitted spans confirmed against the actual source line. The one outlier is a multi-line folded scalar my checker could not match, not a wrong span.
  • All 10 non-contains edges resolve to real nodes, zero unresolved — spot-checked against grep (notify: update-ca-certificates → the handler; roles: [common] → the role module; include_tasks: storage_lvm.yml → the file).
  • graft blast --base HEAD~1 on a commit touching a playbook previously reported "1 changed file not in the graph"; it now names the changed task.

Tests: 21 new in test/ansible-extract.test.ts, following container-extract.test.ts's fixture-as-line-array convention so expected line numbers are readable off the source. Suite goes 1221 → 1242.

The only failures on my machine are the 4 in claude-shim-resolve.test.ts, which I confirmed pre-existing by stashing this change and re-running on a clean tree — they probe the machine's global-install layout.

Notes for review

  • Happy to gate the tier behind a flag if you would rather it be opt-in — claiming .yml/.yaml repo-wide is the most opinionated thing here, which is why a declined file is invisible rather than empty.
  • The bare-module allowlist is the part most likely to need additions from other people's repos.
  • resolve.ts's familyOf gets "ansible" so name resolution is scoped to Ansible files.

🤖 Generated with Claude Code

Ansible is the case none of the existing three tiers can reach. Depth
(extract.ts) and breadth (generic.ts) both ask a grammar "what definitions
does this syntax declare?", and YAML always answers "mappings and
sequences" — a tags.scm over tree-sitter-yaml would call every key in every
manifest, workflow and lock file a definition. Container (container.ts)
does not apply either: there is no embedded language, the YAML is the
program. So the new tier uses the bundled yaml wasm grammar only as a
reader and puts the semantics in code.

Emits `file`, `module` (a role), `class` (a play), `function` (task or
handler) and `variable`, wired by `contains` plus the two edges that make
it a graph rather than an outline: `imports` for include_tasks/import_tasks
and `calls` for include_role/import_role/`roles:` (to the role module) and
`notify:`/`listen:` (to the handler). All resolve through the existing
resolver — `calls` carries `kinds` so a role lands on a module, and an
include is emitted as a file-relative specifier for resolveImport.

Detection is the risk, so it is narrow and structural: a SEQUENCE at the
document root whose items are Ansible-shaped mappings. Kubernetes, Compose
and Actions files are all mapping-rooted and cannot reach the accept path;
a vars file, the one mapping-rooted Ansible shape, is admitted only from a
path Ansible gives meaning to. A declined file yields ZERO nodes — not an
empty file node — and does not add its language to the banner, so a repo of
non-Ansible YAML looks exactly as it did before this tier existed.

Three things this got wrong first and the tests now pin:
- unwrapping with a recursive descendant search read every mapping-rooted
  data file as a sequence (`aggregates:` + a `- name:` list), misdetecting
  eight config files in a real repo. Unwrap passes through wrappers only.
- `comment` is a NAMED node in tree-sitter-yaml, so a playbook opening with
  a comment block has 13 comment children before its content; taking the
  first named child concluded "not a sequence".
- a block node running to the end of its parent swallows the terminating
  newline, so tree-sitter reports row N+1 col 0 and every last-in-parent
  play/task read one line too long.

Bare module keys are matched against a curated list rather than "any
lowercase identifier", which had read `- name: alice` + `description:` as a
task. An FQCN is still accepted structurally. The failure mode is now a
false negative, which costs coverage rather than trust.

Verified on a 617-file Ansible-first GitOps repo: 79 of 214 YAML files
accepted, every one of them under `playbooks/` or `*/ansible/`, and every
decline under those paths a Kubernetes/ArgoCD manifest. 969 of 970 emitted
spans confirmed against the source line (the last is a multi-line folded
scalar the checker cannot match, not a wrong span). 21 new tests; suite
goes 1221 -> 1242 with no new failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@trailhq-graft

trailhq-graft Bot commented Sep 12, 2026

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 6 areas can be affected. 24 dependent symbols, depth 2.
Tests: 1 area updated its tests.
Tag: @anirudhkumar-nanonets — 5 of 7 areas · @shhdwi — 6 of 7 areas · @bhavesh-gupta-investis — Synchronous Execution

flowchart TB
  A0(("Pull Request Review<br/>8 symbols"))
  A1(("Workspace Graph Freshness<br/>6 symbols"))
  A2(("CLI Entry Point<br/>5 symbols"))
  A3(("MCP Tool Invocation<br/>3 symbols"))
  A4(("Viewer Build Script<br/>1 symbol"))
  AX(("1 smaller area<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Pull Request Review 8 src/app/brain-build.ts:L251-L358 readRepository — calls, depth 1 Graph Construction
Workspace Graph Freshness 6 src/graph/refresh.ts:L150-L227 ensureFreshGraph — calls, depth 1 Graph Construction
CLI Entry Point 5 src/cli.ts:L135-L144 warnUnsupportedExtensions — calls, depth 1 Graph Construction
MCP Tool Invocation 3 src/mcp/tools.ts:L216-L244 callTool — calls, depth 2 Graph Construction
Viewer Build Script 1 scripts/build-viewer.mjs:L1-L45 build-viewer.mjs — calls, depth 2 Graph Construction
Synchronous Execution 1 src/claude/sync-run.ts:L19-L33 runSync — calls, depth 2 Graph Construction
Who knows this code — 3 people across 7 areas
Area Who knows it
Graph Construction · changed @anirudhkumar-nanonets — 21 commits, last 16d ago · @shhdwi — 16 commits, last 30d ago
Pull Request Review · affected @anirudhkumar-nanonets — 12 commits, last 2d ago
Workspace Graph Freshness · affected @anirudhkumar-nanonets — 11 commits, last 1mo ago · @shhdwi — 6 commits, last 1mo ago
CLI Entry Point · affected @anirudhkumar-nanonets — 41 commits, last 2d ago · @shhdwi — 24 commits, last 30d ago
MCP Tool Invocation · affected @shhdwi — 14 commits, last 1mo ago · @anirudhkumar-nanonets — 7 commits, last 10d ago
Viewer Build Script · affected @shhdwi — 2 commits, last 1mo ago
Synchronous Execution · affected @shhdwi — 3 commits, last 2mo ago · @bhavesh-gupta-investis — 1 commit, last 19d ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 24 dependent symbols, grouped by area

Pull Request Review — 8 symbols in 6 files

  • src/app/brain-build.ts:L251-L358 — readRepository (calls, depth 1)
    282: await buildGraph(checkout.dir, { graphOnly: true });
  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 1)
    55: await buildGraph(checkout.dir);
  • src/app/brain-build-worker.ts:L1-L83 — brain-build-worker.ts (calls, depth 2)
    68: // No creds: this half needs none. resolveRepoRead already did every call
  • src/app/brain-build-worker.ts:L29-L32 — DoneMessage (references, depth 2)
  • src/app/brain-build.ts:L237-L239 — buildRepoIntoBrain (calls, depth 2)
  • src/app/review-process.ts:L179-L183 — childReviewer (references, depth 2)
  • src/app/review-worker.ts:L67-L87 — run (calls, depth 2)
    75: const id = (seq += 1);
  • src/app/server.ts:L34-L46 — AppSeams (references, depth 2)

Workspace Graph Freshness — 6 symbols in 4 files

  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 1)
    200: // of them pure waste, and the last tool call pays for all of it. A null drift
  • src/graph/workspace.ts:L630-L655 — federateCheck (calls, depth 1)
    638: const g = await checkGraph(join(root, child));
  • src/graph/fingerprint.ts:L150-L196 — probeDrift (calls, depth 2)
    168: // same content) doesn't cost a rebuild. An entry with an empty hash lands
  • src/graph/refresh.ts:L235-L261 — ensureFreshChildren (calls, depth 2)
  • src/graph/workspace-cli.ts:L49-L70 — buildChild (calls, depth 2)
  • src/graph/workspace-cli.ts:L126-L130 — runWorkspaceCheck (calls, depth 2)

CLI Entry Point — 5 symbols in 2 files

  • src/cli.ts:L135-L144 — warnUnsupportedExtensions (calls, depth 1)
    143: console.error(` supported: ${supportedExtensions().join(" ")}`);
  • src/engine.ts:L91-L101 — graph (calls, depth 1)
    92: return buildGraph(dir, {
  • src/engine.ts:L82-L84 — checkGraph (calls, depth 1)
    82: checkGraph(dir: string): Promise<GraphCheckResult> {
  • src/cli.ts:L168-L178 — refreshBefore (calls, depth 2)
  • src/cli.ts:L1-L1407 — cli.ts (calls, depth 2)
    1: #!/usr/bin/env node

MCP Tool Invocation — 3 symbols in 1 file

  • src/mcp/tools.ts:L216-L244 — callTool (calls, depth 2)
  • src/mcp/tools.ts:L247-L329 — callSingleTool (calls, depth 2)
    272: const g = await engine.checkGraph(root);
  • src/mcp/tools.ts:L153-L199 — callWorkspaceTool (calls, depth 2)

Viewer Build Script — 1 symbol in 1 file

  • scripts/build-viewer.mjs:L1-L45 — build-viewer.mjs (calls, depth 2)
    7: import { mkdirSync, copyFileSync, readdirSync } from "node:fs";

Synchronous Execution — 1 symbol in 1 file

  • src/claude/sync-run.ts:L19-L33 — runSync (calls, depth 2)
Test signal per changed area — 1 ✓

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • Graph Construction — 8 of 37 reached · 1 test file changed here: test/ansible-extract.test.ts
    • not reached: ansibleShape, asMapping, asSequence, content, emitTask, emitTaskList, call, constructor, …21 more
34 test suites also reference this code

42 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/container-extract.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • test/graph-r-phase4.test.ts
  • test/graph-r-phase5.test.ts
  • test/graph-r.test.ts
  • …14 more

graft blast · origin/main...HEAD · depth 2 · 6 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants