agent-doc-system 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,152 +1,99 @@
1
- # agentdoc — portable documentation & software-catalog system
1
+ # AgentDoc
2
2
 
3
- Zero runtime dependencies. One Node runtime. AgentDoc compiles a repository
4
- into one deterministic graph and routes a path or an entity to the minimum
5
- context needed to change it safely.
3
+ ![AgentDoc — Give coding agents the context they actually need](docs/assets/readme/agentdoc-hero.webp)
6
4
 
7
- It exists to answer one question cheaply and correctly, for an AI coding agent
8
- or a human reviewer:
5
+ **Give coding agents the context they actually need.**
9
6
 
10
- > I am about to change *this*. What do I need to know, what must I keep
11
- > consistent, and which command proves it?
7
+ AgentDoc turns your codebase, docs, contracts, and runtime evidence into a context layer your coding agent can query before it changes anything.
12
8
 
13
- It does that while keeping three kinds of claim apart — what the repository
14
- *claims*, what can be *derived* from code, and what was *observed* running —
15
- and surfacing their disagreements as conflicts instead of guessing.
9
+ [![npm](https://img.shields.io/npm/v/agent-doc-system.svg)](https://www.npmjs.com/package/agent-doc-system) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![node](https://img.shields.io/badge/node-22%20%7C%2024%20%7C%2026-43853d.svg)](https://nodejs.org/)
16
10
 
17
- ## Install the CLI
11
+ ## Give this to your coding agent
18
12
 
19
- The CLI is installed once per machine. Requires Node 22 or newer (22, 24 and
20
- 26 are the tested baselines).
13
+ Paste this into your agent **from the repository you want to use AgentDoc on**:
21
14
 
22
- ```bash
23
- npm install -g github:lbframe/agent-doc-system
24
-
25
- agentdoc --version
26
- agentdoc doctor # machine self-check
15
+ ```text
16
+ Set up AgentDoc for this repository and use it as the context layer for your work.
17
+
18
+ AgentDoc: https://github.com/lbframe/agent-doc-system
19
+
20
+ 1. Read INSTALL_FOR_AGENTS.md and install or upgrade the AgentDoc CLI.
21
+ 2. Read skills/agent-doc-system/SKILL.md.
22
+ 3. Run agentdoc doctor --project and agentdoc audit.
23
+ 4. Use the appropriate CREATE or MIGRATE workflow for this repository.
24
+ 5. Do not invent ownership, architecture, authority rules, or product intent. Ask me only for decisions AgentDoc cannot safely derive.
25
+ 6. Finish setup with agentdoc validate, agentdoc compile, and agentdoc check.
26
+ 7. From now on, use agentdoc query and agentdoc impact before changes, then agentdoc check after changes.
27
+ 8. Report anything unresolved or conflicting instead of guessing.
27
28
  ```
28
29
 
29
- Unpinned installs track `main`. For reproducible CI, pin a tag or commit:
30
- `github:lbframe/agent-doc-system#v1.0.0`.
30
+ That is the intended onboarding path: **give the prompt to the agent, let AgentDoc build the context layer, and keep using it during development.**
31
31
 
32
- The install footprint is the CLI only — `bin/`, `core/`, `adapters/`,
33
- `schemas/`, `templates/`, `evals/`, docs and the agent skill. Nothing is ever
34
- copied into a project that uses AgentDoc.
32
+ ## Why AgentDoc?
35
33
 
36
- ### Have an AI agent install it for you
34
+ Coding agents can read code. The hard part is knowing **what matters for this change**.
37
35
 
38
- Paste this into a capable coding agent:
36
+ Important context is scattered across code, documentation, API contracts, architectural decisions, CI rules, and production reality. Agents either load too much or miss something important.
39
37
 
40
- ```text
41
- Install AgentDoc on this machine from:
42
- https://github.com/lbframe/agent-doc-system
38
+ AgentDoc gives them a map.
43
39
 
44
- Read INSTALL_FOR_AGENTS.md from that repository and follow it completely.
40
+ - **Query the right context** for a file or component.
41
+ - **See the blast radius** before changing code.
42
+ - **Detect drift** between code and documentation.
43
+ - **Surface conflicts** instead of silently guessing.
44
+ - **Verify constraints in CI** before stale knowledge becomes trusted context.
45
45
 
46
- Do not initialize, inspect, migrate, or modify any software project as part of
47
- this installation task.
46
+ ![AgentDoc in action — query, impact, check](docs/assets/readme/agentdoc-in-action.webp)
48
47
 
49
- When finished, verify the AgentDoc CLI and report the installed version.
50
- ```
51
-
52
- [`INSTALL_FOR_AGENTS.md`](INSTALL_FOR_AGENTS.md) contains the procedure.
53
-
54
- ## Use it in a project
55
-
56
- From the repository root:
48
+ ## Three commands, one workflow
57
49
 
58
50
  ```bash
59
- cd my-project
60
- agentdoc doctor --project # is there a catalog here, and is it healthy
61
- agentdoc audit # inventory the repo, classify every fact
51
+ agentdoc query src/payments/webhook.ts
52
+ agentdoc impact src/payments/webhook.ts
53
+ agentdoc check
62
54
  ```
63
55
 
64
- Then follow the workflow the audit implies:
65
-
66
- | workflow | when | entry point |
67
- |---|---|---|
68
- | CREATE | no catalog exists | `agentdoc init`, then `agentdoc scaffold --write` |
69
- | MIGRATE | established repo, scattered or stale docs | `agentdoc audit`, work the report |
70
- | VALIDATE | before committing, in CI | `agentdoc validate` → `compile` → `check` |
71
- | QUERY | before and during a change | `agentdoc query <path-or-ref>` |
72
- | MAINTAIN | after a change | `agentdoc impact <paths>` |
56
+ `query` tells the agent what it needs to know. `impact` shows what the change can affect. `check` verifies that the repository still agrees with itself.
73
57
 
74
- AgentDoc writes **project data only**: `agentdoc/` (configuration and
75
- descriptors you own), `docs/` skeleton files, `.agentdoc/graph.json` (the
76
- compiled artifact), and a CI workflow. The CLI source is never vendored into
77
- your repository.
58
+ ## Why not just AGENTS.md or RAG?
78
59
 
79
- The two directories differ in ownership, not just punctuation:
60
+ ![Why AgentDoc](docs/assets/readme/why-agentdoc.webp)
80
61
 
81
- - `agentdoc/` is **authored** — configuration, descriptors and observation
82
- sets. Everything in it is a claim you wrote and a reviewer can diff.
83
- - `.agentdoc/` is **generated** — the compiled graph and other build
84
- artifacts. Never edit it by hand; `compile` rewrites it and `check` rejects
85
- a stale or edited one.
62
+ AgentDoc is not another documentation file and it is not just semantic search.
86
63
 
87
- ## Use it with an AI agent
64
+ It combines **authored intent**, **facts derived from code**, and **runtime observations** without pretending they are the same thing. When sources disagree, AgentDoc surfaces the conflict instead of choosing a convenient answer.
88
65
 
89
- The agent skill is [`skills/agent-doc-system/`](skills/agent-doc-system/SKILL.md).
90
- Install that directory as a skill in your agent harness. It teaches the agent
91
- when to use the CLI, the evidence-class model, the working loop, and the
92
- integrity rules — with deeper material under `skills/agent-doc-system/references/`.
66
+ ## From repository knowledge to agent-ready context
93
67
 
94
- ## What it is built on
68
+ AgentDoc turns scattered repository knowledge into task-specific context your coding agent can query, verify, and act on.
95
69
 
96
- Most repositories conflate three different kinds of claim, and the confusion
97
- is the single largest source of wrong agent behaviour:
70
+ ![How AgentDoc works](docs/assets/readme/how-agentdoc-works.webp)
98
71
 
99
- | what | who says it | how it fails |
100
- |---|---|---|
101
- | what the repository **claims** | authored docs, config, manifests | drifts, is aspirational, is not tested |
102
- | what can be **derived** from code | imports, manifests, contracts | heuristic, incomplete, wrong about intent |
103
- | what was **observed** in a running system | schedulers, bindings, live APIs | goes stale, disagrees with intent |
72
+ ## Install manually
104
73
 
105
- A claim carries an evidence class, a confidence, and a pointer to its evidence.
106
- When two classes disagree about the same fact, the disagreement is **surfaced
107
- as a conflict**, not reconciled. There is no global precedence order, because
108
- authority depends on the fact: the committed manifest is authoritative for
109
- *intent*, the live scheduler is authoritative for *what fires*, and the
110
- canonical contract is authoritative for the *desired wire surface* while a
111
- live probe is authoritative for *what is deployed today*.
74
+ Requires Node.js 22+.
112
75
 
113
- The default is fail-closed. A fact with no governing authority rule and
114
- disagreeing evidence is an error, and no gate passes while it stands.
115
-
116
- ## Documentation
76
+ ```bash
77
+ npm install -g agent-doc-system
78
+ agentdoc doctor
79
+ ```
117
80
 
118
- | file | covers |
119
- |---|---|
120
- | [`skills/agent-doc-system/SKILL.md`](skills/agent-doc-system/SKILL.md) | how an agent uses the system |
121
- | [INSTALL_FOR_AGENTS.md](INSTALL_FOR_AGENTS.md) | machine installation, for an AI agent |
122
- | [docs/SPEC.md](docs/SPEC.md) | the normative specification: entities, graph, determinism |
123
- | [docs/AUTHORITY.md](docs/AUTHORITY.md) | evidence classes, conflict semantics, elections |
124
- | [docs/PROVENANCE.md](docs/PROVENANCE.md) | evidence records, pointers, secret handling |
125
- | [docs/QUERY.md](docs/QUERY.md) | the context router, budgets, freshness refusal |
126
- | [docs/MIGRATION.md](docs/MIGRATION.md) | CREATE and MIGRATE workflows |
127
- | [docs/CI.md](docs/CI.md) | the drift gate and its failure modes |
128
- | [docs/ADAPTERS.md](docs/ADAPTERS.md) | writing an adapter, and what must never live in one |
129
- | [docs/EVALS.md](docs/EVALS.md) | the routing benchmark and pre-registered thresholds |
130
- | [docs/COMPARISON-KODA.md](docs/COMPARISON-KODA.md) | measured comparison against the reference corpus |
131
-
132
- ## Fixtures and the golden corpus
133
-
134
- - `fixtures/a-node-ts-postgres` — single Node/TypeScript service, REST, Postgres, GitHub Actions.
135
- - `fixtures/b-go-multi-service` — multi-service Go repository, protobuf contracts, shared library, resources.
136
- - `fixtures/c-alt-monorepo` — deliberately *not* `apps/`+`packages/`+`workers/`, mixed runtimes, inconsistent pre-existing docs.
137
- - `examples/koda` — the profile that represents a large, mature, TypeScript+Go monorepo, including the runtime-observation conflicts that motivated the authority model.
138
-
139
- ## Development
81
+ Then, inside a repository:
140
82
 
141
83
  ```bash
142
- node --test tests/*.test.mjs # unit, adversarial and mode tests
143
- node evals/run-all.mjs # aggregate routing evaluation (gate)
144
- npm run selfcheck # agentdoc doctor, from the source checkout
84
+ agentdoc audit
145
85
  ```
146
86
 
147
- ## Licence of the design
87
+ AgentDoc also ships with an Agent Skill at [`skills/agent-doc-system/`](skills/agent-doc-system/SKILL.md).
88
+
89
+ ## Documentation
90
+
91
+ - [Getting started & migration](docs/MIGRATION.md)
92
+ - [Authority & conflicts](docs/AUTHORITY.md)
93
+ - [CI & verification](docs/CI.md)
94
+ - [Specification](docs/SPEC.md)
95
+ - [Agent Skill](skills/agent-doc-system/SKILL.md)
96
+
97
+ ## License
148
98
 
149
- The architecture, the fail-closed discipline, the provenance model and the
150
- warning-acceptance mechanism are extracted from a production catalog system
151
- that proved them at scale. The namespace, schemas, adapters, CLI and authority
152
- model here are new and neutral.
99
+ [MIT](LICENSE) © 2026 lbframe
@@ -0,0 +1,306 @@
1
+ // Ansible adapter: what host provisioning actually installs.
2
+ //
3
+ // Reads the playbooks under a unit root without running anything and without
4
+ // evaluating a single Jinja expression. From every task reachable from a
5
+ // playbook (plays, roles, includes, blocks) it records the host paths the
6
+ // task creates and the services it manages. That inventory is the DERIVED side
7
+ // of a normative requirement whose `satisfiedBy` names this adapter and whose
8
+ // `expects` names a path or a service:
9
+ //
10
+ // provided a reachable, unconditional task installs it deterministic
11
+ // absent no reachable task does, and every task is transparent deterministic
12
+ //
13
+ // Anything weaker is a candidate, which the authority engine never elects: a
14
+ // matching task behind a `when`, or an absence claimed over a tree that holds
15
+ // an opaque task (a shell command, a package install, a templated path, a role
16
+ // that is not in the repository). An absence is only a fact when the adapter
17
+ // could have seen the opposite.
18
+ import { Adapter } from "./registry.mjs";
19
+ import { parseYamlDocuments } from "../core/yaml.mjs";
20
+ import { AgentDocError } from "../core/codes.mjs";
21
+
22
+ // Modules that create the path they name, and the arguments naming it.
23
+ const PATH_MODULES = {
24
+ copy: ["dest"],
25
+ template: ["dest"],
26
+ unarchive: ["dest", "creates"],
27
+ get_url: ["dest"],
28
+ git: ["dest"],
29
+ synchronize: ["dest"],
30
+ pip: ["virtualenv"],
31
+ };
32
+ // `file` creates only for these states; `lineinfile`/`blockinfile` only with
33
+ // `create: true`.
34
+ const FILE_CREATING_STATES = new Set(["directory", "touch", "link", "hard"]);
35
+ const SERVICE_MODULES = new Set(["systemd", "systemd_service", "service"]);
36
+ // Commands can do anything; their `creates` is the one thing they promise.
37
+ const COMMAND_MODULES = new Set(["command", "shell", "script", "raw"]);
38
+ // Modules that change no file the adapter would be asked about.
39
+ const INERT_MODULES = new Set([
40
+ "debug", "set_fact", "assert", "fail", "meta", "pause", "wait_for", "stat", "ping", "setup",
41
+ "include_vars", "group_by", "add_host", "user", "group", "uri", "slurp", "fetch", "set_stats",
42
+ ]);
43
+ // Task keywords, i.e. everything on a task that is not its module.
44
+ const TASK_KEYWORDS = new Set([
45
+ "name", "when", "loop", "with_items", "with_dict", "with_fileglob", "loop_control", "register", "notify",
46
+ "become", "become_user", "become_method", "tags", "vars", "environment", "args", "ignore_errors", "changed_when",
47
+ "failed_when", "until", "retries", "delay", "no_log", "delegate_to", "run_once", "check_mode", "diff",
48
+ "listen", "timeout", "throttle", "any_errors_fatal", "module_defaults", "collections", "connection",
49
+ ]);
50
+ const INCLUDES = new Set(["include_tasks", "import_tasks", "include"]);
51
+ const ROLE_INCLUDES = new Set(["include_role", "import_role"]);
52
+
53
+ export class AnsibleAdapter extends Adapter {
54
+ static adapterName = "ansible";
55
+ constructor() {
56
+ super({ name: "ansible", version: "1.0.0", kind: "source" });
57
+ }
58
+
59
+ // A unit that holds a playbook is a deployable infrastructure unit.
60
+ detect(ctx, root) {
61
+ const pb = playbooks(ctx.repo, root);
62
+ return pb.length ? { path: pb[0].path, shape: "deployable" } : null;
63
+ }
64
+
65
+ extract(ctx, unit) {
66
+ const inv = inventory(ctx.repo, unit.root);
67
+ if (!inv.files.length) return {};
68
+ for (const r of ctx.requirements || []) {
69
+ if (r.subject !== ctx.compRef || !r.satisfiedBy.includes("ansible") || !r.expects) continue;
70
+ const verdict = judge(inv, r.expects);
71
+ const files = verdict.files.length ? verdict.files : inv.files;
72
+ ctx.addFact(r.subject, r.key, verdict.value, {
73
+ evidenceClass: "DERIVED",
74
+ confidence: verdict.certain ? "deterministic" : "candidate",
75
+ provRecs: files.map((f) => ctx.prov.add("observed", f, "ansible-extractor", ["ass:pending"])),
76
+ semantics: "ansible: " + verdict.why,
77
+ });
78
+ }
79
+ return {
80
+ provisioning: {
81
+ installs: inv.installs.map((i) => ({ path: i.path, file: i.file, task: i.task, conditional: i.conditional })),
82
+ services: inv.services.map((s) => ({ name: s.name, file: s.file, task: s.task, conditional: s.conditional })),
83
+ opaque: inv.opaque,
84
+ },
85
+ };
86
+ }
87
+ }
88
+
89
+ function judge(inv, expects) {
90
+ const target = expects.installs || expects.service;
91
+ const what = expects.installs ? "path " + target : "service " + target;
92
+ const hits = expects.installs
93
+ ? inv.installs.filter((i) => i.path === target || i.path.startsWith(target + "/"))
94
+ : inv.services.filter((s) => s.name === target || s.name === target + ".service");
95
+ const sure = hits.filter((h) => !h.conditional);
96
+ if (sure.length) {
97
+ return { value: "provided", certain: true, files: uniq(sure.map((h) => h.file)), why: what + " installed by " + describe(sure[0]) };
98
+ }
99
+ if (hits.length) {
100
+ return { value: "provided", certain: false, files: uniq(hits.map((h) => h.file)), why: what + " installed only conditionally by " + describe(hits[0]) };
101
+ }
102
+ if (inv.opaque.length) {
103
+ return {
104
+ value: "absent", certain: false, files: [],
105
+ why: "no task installs " + what + ", but absence cannot be proven: " + inv.opaque.slice(0, 3).join("; ") +
106
+ (inv.opaque.length > 3 ? " (+" + (inv.opaque.length - 3) + " more)" : ""),
107
+ };
108
+ }
109
+ return { value: "absent", certain: true, files: [], why: "no reachable task installs " + what };
110
+ }
111
+
112
+ function describe(h) {
113
+ return h.file + (h.task ? " (" + h.task + ")" : "");
114
+ }
115
+
116
+ function uniq(a) {
117
+ return [...new Set(a)].sort();
118
+ }
119
+
120
+ function parse(repo, file) {
121
+ try {
122
+ return parseYamlDocuments(repo.readText(file), file, { allowSequenceRoot: true }).map((d) => d.doc);
123
+ } catch (e) {
124
+ if (e instanceof AgentDocError) return null;
125
+ throw e;
126
+ }
127
+ }
128
+
129
+ function isYaml(f) {
130
+ return /\.ya?ml$/.test(f);
131
+ }
132
+
133
+ function playbooks(repo, root) {
134
+ const out = [];
135
+ for (const f of repo.walk(root)) {
136
+ if (!isYaml(f) || /(^|\/)(tasks|handlers|vars|defaults|meta|group_vars|host_vars|templates|files)\//.test(f.slice(root.length))) continue;
137
+ const docs = parse(repo, f);
138
+ if (!docs) continue;
139
+ const plays = docs.flatMap((d) => (Array.isArray(d) ? d : []));
140
+ if (plays.length && plays.every((p) => p && typeof p === "object" && (p.hosts !== undefined || p.import_playbook !== undefined))) {
141
+ out.push({ path: f, plays });
142
+ }
143
+ }
144
+ return out.sort((a, b) => (a.path < b.path ? -1 : 1));
145
+ }
146
+
147
+ // Every task reachable from a playbook under `root`, walked in a fixed order.
148
+ function inventory(repo, root) {
149
+ const inv = { files: new Set(), installs: [], services: [], opaque: [] };
150
+ const seen = new Set();
151
+ const opaque = (file, task, why) => inv.opaque.push(file + (task ? " (" + task + ")" : "") + ": " + why);
152
+
153
+ const dirOf = (p) => p.split("/").slice(0, -1).join("/");
154
+ const join = (dir, rel) => {
155
+ const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
156
+ const out = [];
157
+ for (const s of parts) {
158
+ if (s === "" || s === ".") continue;
159
+ if (s === "..") { if (!out.length) return null; out.pop(); } else out.push(s);
160
+ }
161
+ return out.join("/");
162
+ };
163
+ const templated = (v) => typeof v === "string" && /\{\{|\{%/.test(v);
164
+
165
+ const findRole = (name, fromDir) => {
166
+ for (const base of [fromDir, root]) {
167
+ for (const ext of ["yml", "yaml"]) {
168
+ const p = join(base, "roles/" + name + "/tasks/main." + ext);
169
+ if (p && repo.exists(p)) return p;
170
+ }
171
+ }
172
+ return null;
173
+ };
174
+
175
+ const walkFile = (file, conditional, kind) => {
176
+ const key = file + "|" + conditional + "|" + kind;
177
+ if (seen.has(key)) return;
178
+ seen.add(key);
179
+ inv.files.add(file);
180
+ const docs = parse(repo, file);
181
+ if (!docs) { opaque(file, null, "not parseable as YAML"); return; }
182
+ const list = docs.flatMap((d) => (Array.isArray(d) ? d : []));
183
+ if (kind === "playbook") for (const p of list) walkPlay(file, p, conditional);
184
+ else walkTasks(file, list, conditional);
185
+ };
186
+
187
+ const walkPlay = (file, play, conditional) => {
188
+ if (!play || typeof play !== "object") return;
189
+ if (play.import_playbook !== undefined) {
190
+ const ref = String(play.import_playbook);
191
+ const p = !templated(ref) && join(dirOf(file), ref);
192
+ if (p && repo.exists(p)) walkFile(p, conditional || play.when !== undefined, "playbook");
193
+ else opaque(file, null, "imported playbook not resolvable in the repository: " + ref);
194
+ return;
195
+ }
196
+ const cond = conditional || play.when !== undefined;
197
+ for (const section of ["pre_tasks", "roles", "tasks", "post_tasks", "handlers"]) {
198
+ const items = play[section];
199
+ if (!Array.isArray(items)) continue;
200
+ if (section === "roles") {
201
+ for (const r of items) {
202
+ const name = typeof r === "string" ? r : r && (r.role || r.name);
203
+ walkRole(file, String(name), cond || (r && typeof r === "object" && r.when !== undefined));
204
+ }
205
+ } else {
206
+ walkTasks(file, items, cond);
207
+ }
208
+ }
209
+ };
210
+
211
+ const walkRole = (file, name, conditional) => {
212
+ const main = !templated(name) && findRole(name, dirOf(file));
213
+ if (main) walkFile(main, conditional, "tasks");
214
+ else opaque(file, null, "role not found in the repository: " + name);
215
+ };
216
+
217
+ const walkTasks = (file, tasks, conditional) => {
218
+ for (const t of tasks) {
219
+ if (!t || typeof t !== "object" || Array.isArray(t)) continue;
220
+ const cond = conditional || t.when !== undefined;
221
+ const label = typeof t.name === "string" ? t.name : null;
222
+ if (Array.isArray(t.block)) {
223
+ walkTasks(file, t.block, cond);
224
+ // A rescue runs only when the block failed: never an unconditional install.
225
+ for (const k of ["rescue", "always"]) if (Array.isArray(t[k])) walkTasks(file, t[k], cond || k === "rescue");
226
+ continue;
227
+ }
228
+ const modKey = Object.keys(t).find((k) => !TASK_KEYWORDS.has(k));
229
+ if (!modKey) continue;
230
+ const mod = modKey.split(".").pop();
231
+ const raw = t[modKey];
232
+ const args = { ...parseFreeForm(raw), ...(t.args && typeof t.args === "object" ? t.args : {}) };
233
+ // A loop runs the module once per item with a templated argument; the
234
+ // adapter does not expand it.
235
+ const looped = t.loop !== undefined || Object.keys(t).some((k) => k.startsWith("with_"));
236
+
237
+ if (INCLUDES.has(mod)) {
238
+ const ref = typeof raw === "string" ? raw.split(/\s+/)[0] : args.file;
239
+ const p = ref && !templated(ref) && join(dirOf(file), String(ref));
240
+ if (p && repo.exists(p)) walkFile(p, cond, "tasks");
241
+ else opaque(file, label, "included task file not resolvable in the repository: " + ref);
242
+ continue;
243
+ }
244
+ if (ROLE_INCLUDES.has(mod)) {
245
+ walkRole(file, String(args.name), cond);
246
+ continue;
247
+ }
248
+ const record = (list, entry) => list.push({ ...entry, file, task: label, conditional: cond });
249
+ if (PATH_MODULES[mod] || mod === "file" || mod === "lineinfile" || mod === "blockinfile") {
250
+ let names = PATH_MODULES[mod] || ["path", "dest", "name"];
251
+ if (mod === "file" && !FILE_CREATING_STATES.has(String(args.state || "file"))) names = [];
252
+ if ((mod === "lineinfile" || mod === "blockinfile") && !isTrue(args.create)) names = [];
253
+ for (const n of names) {
254
+ const v = args[n];
255
+ if (v === undefined) continue;
256
+ if (templated(v) || looped) { opaque(file, label, mod + " " + n + " is templated: " + v); continue; }
257
+ record(inv.installs, { path: normalisePath(String(v)) });
258
+ }
259
+ continue;
260
+ }
261
+ if (SERVICE_MODULES.has(mod)) {
262
+ const n = args.name;
263
+ if (n === undefined) continue;
264
+ if (templated(n) || looped) opaque(file, label, mod + " name is templated: " + n);
265
+ else record(inv.services, { name: String(n) });
266
+ continue;
267
+ }
268
+ if (COMMAND_MODULES.has(mod)) {
269
+ if (args.creates !== undefined && !templated(args.creates) && !looped) {
270
+ record(inv.installs, { path: normalisePath(String(args.creates)) });
271
+ } else {
272
+ opaque(file, label, mod + " task without a literal `creates`");
273
+ }
274
+ continue;
275
+ }
276
+ if (INERT_MODULES.has(mod)) continue;
277
+ opaque(file, label, "module " + modKey + " is not modeled and may change the host");
278
+ }
279
+ };
280
+
281
+ for (const pb of playbooks(repo, root)) walkFile(pb.path, false, "playbook");
282
+ const byPath = (a, b) => (a.path + a.file < b.path + b.file ? -1 : a.path + a.file > b.path + b.file ? 1 : 0);
283
+ return {
284
+ files: [...inv.files].sort(),
285
+ installs: inv.installs.sort(byPath),
286
+ services: inv.services.sort((a, b) => (a.name + a.file < b.name + b.file ? -1 : 1)),
287
+ opaque: [...new Set(inv.opaque)].sort(),
288
+ };
289
+ }
290
+
291
+ // `file: path=/a state=directory` — the key=value shorthand.
292
+ function parseFreeForm(raw) {
293
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw;
294
+ if (typeof raw !== "string") return {};
295
+ const out = {};
296
+ for (const m of raw.matchAll(/(\w+)=("[^"]*"|'[^']*'|\S+)/g)) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
297
+ return out;
298
+ }
299
+
300
+ function isTrue(v) {
301
+ return v === true || /^(yes|true)$/i.test(String(v));
302
+ }
303
+
304
+ function normalisePath(p) {
305
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
306
+ }
@@ -12,11 +12,11 @@ export class GitHubActionsAdapter extends Adapter {
12
12
  constructor() {
13
13
  super({ name: "github-actions", version: "1.0.0", kind: "ci" });
14
14
  }
15
- detect(ctx, root) {
16
- const p = root + "/.github/workflows";
17
- if (!ctx.repo.isDir(p)) return null;
18
- return { path: p, shape: "importable" };
19
- }
15
+ // No detect(): a workflow directory is not a unit marker. It is not evidence
16
+ // of an importable or deployable artifact, and a marker path is hashed as a
17
+ // file for provenance, so a directory marker can only fail. GitHub runs only
18
+ // the repository-root .github/workflows; a nested one (vendored upstream, a
19
+ // subtree) is inert and is ignored here too.
20
20
  contracts(ctx) {
21
21
  const dir = ".github/workflows";
22
22
  if (!ctx.repo.isDir(dir)) return [];
@@ -20,13 +20,14 @@ import { EventsAdapter } from "./events.mjs";
20
20
  import { DeployConfigAdapter } from "./deploy-config.mjs";
21
21
  import { ObjectStoreAdapter } from "./object-store.mjs";
22
22
  import { CacheAdapter } from "./cache.mjs";
23
+ import { AnsibleAdapter } from "./ansible.mjs";
23
24
 
24
25
  const REGISTRY = new Map();
25
26
  for (const C of [
26
27
  GenericAdapter, NodeAdapter, PnpmAdapter, TurborepoAdapter, TypeScriptAdapter,
27
28
  GoAdapter, OpenApiAdapter, ProtobufAdapter, GraphQLAdapter, OidcAdapter,
28
29
  GitHubActionsAdapter, DockerfileAdapter, WranglerAdapter, DatabaseAdapter,
29
- EventsAdapter, DeployConfigAdapter, ObjectStoreAdapter, CacheAdapter,
30
+ EventsAdapter, DeployConfigAdapter, ObjectStoreAdapter, CacheAdapter, AnsibleAdapter,
30
31
  ]) {
31
32
  REGISTRY.set(C.prototype.constructor.adapterName, new C());
32
33
  }