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 +63 -116
- package/adapters/ansible.mjs +306 -0
- package/adapters/github-actions.mjs +5 -5
- package/adapters/index.mjs +2 -1
- package/bin/agentdoc.mjs +59 -43
- package/bin/commands.mjs +169 -0
- package/bin/usage.txt +5 -3
- package/core/audit.mjs +50 -1
- package/core/authority/engine.mjs +27 -4
- package/core/codes.mjs +7 -0
- package/core/compile.mjs +139 -2
- package/core/descriptors.mjs +5 -2
- package/core/fsx.mjs +10 -4
- package/core/graph.mjs +1 -1
- package/core/normative.mjs +171 -0
- package/core/yaml.mjs +5 -3
- package/docs/ADAPTERS.md +36 -1
- package/docs/AUTHORITY.md +10 -1
- package/docs/CI.md +3 -1
- package/docs/MIGRATION.md +72 -4
- package/docs/assets/readme/agentdoc-hero.webp +0 -0
- package/docs/assets/readme/agentdoc-in-action.webp +0 -0
- package/docs/assets/readme/how-agentdoc-works.webp +0 -0
- package/docs/assets/readme/why-agentdoc.webp +0 -0
- package/docs/ci/github-actions.yml +1 -1
- package/package.json +1 -1
- package/schemas/common.schema.json +40 -0
- package/schemas/component.schema.json +5 -1
- package/schemas/config.schema.json +117 -1
- package/skills/agent-doc-system/SKILL.md +5 -1
- package/skills/agent-doc-system/references/cli.md +12 -4
- package/skills/agent-doc-system/references/create-migrate.md +15 -3
- package/templates/ci.yml +1 -1
package/README.md
CHANGED
|
@@ -1,152 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AgentDoc
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
into one deterministic graph and routes a path or an entity to the minimum
|
|
5
|
-
context needed to change it safely.
|
|
3
|
+

|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
or a human reviewer:
|
|
5
|
+
**Give coding agents the context they actually need.**
|
|
9
6
|
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
*claims*, what can be *derived* from code, and what was *observed* running —
|
|
15
|
-
and surfacing their disagreements as conflicts instead of guessing.
|
|
9
|
+
[](https://www.npmjs.com/package/agent-doc-system) [](LICENSE) [](https://nodejs.org/)
|
|
16
10
|
|
|
17
|
-
##
|
|
11
|
+
## Give this to your coding agent
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
26 are the tested baselines).
|
|
13
|
+
Paste this into your agent **from the repository you want to use AgentDoc on**:
|
|
21
14
|
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
Coding agents can read code. The hard part is knowing **what matters for this change**.
|
|
37
35
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
Install AgentDoc on this machine from:
|
|
42
|
-
https://github.com/lbframe/agent-doc-system
|
|
38
|
+
AgentDoc gives them a map.
|
|
43
39
|
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
this installation task.
|
|
46
|
+

|
|
48
47
|
|
|
49
|
-
|
|
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
|
-
|
|
60
|
-
agentdoc
|
|
61
|
-
agentdoc
|
|
51
|
+
agentdoc query src/payments/webhook.ts
|
|
52
|
+
agentdoc impact src/payments/webhook.ts
|
|
53
|
+
agentdoc check
|
|
62
54
|
```
|
|
63
55
|
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+

|
|
80
61
|
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
AgentDoc turns scattered repository knowledge into task-specific context your coding agent can query, verify, and act on.
|
|
95
69
|
|
|
96
|
-
|
|
97
|
-
is the single largest source of wrong agent behaviour:
|
|
70
|
+

|
|
98
71
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
76
|
+
```bash
|
|
77
|
+
npm install -g agent-doc-system
|
|
78
|
+
agentdoc doctor
|
|
79
|
+
```
|
|
117
80
|
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 [];
|
package/adapters/index.mjs
CHANGED
|
@@ -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
|
}
|