feat(ansible): a fourth tier for YAML whose meaning is in its key names - #365
bogdandragosaccesa wants to merge 1 commit into
Conversation
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>
🌱 graft blast radius1 area changed → 6 areas can be affected. 24 dependent symbols, depth 2. 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;
Who knows this code — 3 people across 7 areas
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 All 24 dependent symbols, grouped by areaPull Request Review — 8 symbols in 6 files
Workspace Graph Freshness — 6 symbols in 4 files
CLI Entry Point — 5 symbols in 2 files
MCP Tool Invocation — 3 symbols in 1 file
Viewer Build Script — 1 symbol in 1 file
Synchronous Execution — 1 symbol in 1 file
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.
34 test suites also reference this code42 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
Adds an Ansible tier: a fourth extractor for YAML whose meaning is in its key names. No new dependencies — it reuses the
tree-sitter-wasmyaml grammar already in the tree.Why not a
GENERIC_LANGSrowI tried to make this fit an existing tier first, and none of them can hold it:
extract.ts) and breadth (generic.ts) both ask a grammar "what definitions does this syntax declare?" YAML's answer is always "mappings and sequences". Atags.scmover 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 problemEXTENSIONS' comment already worries about.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 inansible.ts.What it emits
filemoduleroles/<name>/tasks/main.ymlclass- hosts: web), signaturehosts: …functionansible.builtin.apt)variablevars:block, orset_factWired by
contains, plus the two edges that make it a graph rather than an outline:imports—include_tasks/import_tasks/vars_files/import_playbook, emitted as a file-relative specifier soresolveImportsettles it unchanged.calls—include_role/import_role/roles:(carryingkinds: ["module"]so it lands on the role) andnotify:/listen:(to the handler).No resolver changes. Everything routes through machinery that already exists.
Variable references (
{{ foo }}) are deliberately not edges:resolve.tsonly resolves a bare-namereferencesedge 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'sdefaults//vars/).Two consequences I think matter for this project specifically:
build.tsgains afileNodes.lengthguard onlangs.add(label)for this; it is a no-op for the other tiers, which always emit at least a file node.)ansible.builtin.apt) is accepted structurally. "Any lowercase key is a module" read- name: alice+description: an adminas 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
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.commentis 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 itsblock_node, so taking the first named child concluded "not a sequence" — this rejected nearly every real playbook.endPositionas row N+1 column 0 and every last-in-parent play/task read one line too long. This is exactly the plausible-but-wrongfile:linecontainer.ts's header warns about, sospan()trims it.graft checkparity (#236)check.tsgets the same branch in the same order asbuild.ts. Its existing comment about a tier the build writes and the check cannot see reading asremovedforever 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):
playbooks/or*/ansible/; every decline under those paths is a Kubernetes/ArgoCD manifest that happens to live there.containsedges 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~1on 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, followingcontainer-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
.yml/.yamlrepo-wide is the most opinionated thing here, which is why a declined file is invisible rather than empty.resolve.ts'sfamilyOfgets"ansible"so name resolution is scoped to Ansible files.🤖 Generated with Claude Code