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/core/compile.mjs CHANGED
@@ -18,6 +18,9 @@ import { formatErrors } from "./jsonschema.mjs";
18
18
  import { validateContractFile } from "./contracts.mjs";
19
19
  import { sourceFiles, importSpecifiers } from "./sourcescan.mjs";
20
20
  import { applyWarningAcceptances } from "./acceptances.mjs";
21
+ import {
22
+ loadNormativeDocuments, verifyCitation, normativeCoverage, isNormative, isRequirementKey, NORMATIVE_SEMANTICS_PREFIX,
23
+ } from "./normative.mjs";
21
24
  import { loadAdapters } from "../adapters/index.mjs";
22
25
  import {
23
26
  COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
@@ -115,6 +118,13 @@ export function compile(root, opts = {}) {
115
118
  evidence: o.evidence.map((ev) => ({ path: ev.path, sha256: ev.sha256 })),
116
119
  });
117
120
 
121
+ // ── normative requirements ───────────────────────────────────────────────
122
+ // Checked before discovery so a stale citation is reported alongside every
123
+ // other authored-input error rather than hiding behind a coverage failure.
124
+ const normative = loadNormative(repo, cfg, sources, overrides.overrides);
125
+ res.errors.push(...normative.errors);
126
+ res.normative = { sections: normative.sections, uncovered: normative.uncovered, requirements: normative.requirements };
127
+
118
128
  res.errors.push(...checkContractCoverage({ repo, cfg, sources }));
119
129
 
120
130
  for (const api of sources.entities.filter((e) => e.kind === "API")) {
@@ -165,6 +175,9 @@ export function compile(root, opts = {}) {
165
175
  repo, cfg, refs, bundle, prov, diagnostics, facts,
166
176
  sources: { ...sources, journeys: journeys.journeys }, discovery, journeys: journeys.journeys, journeysPath: journeys.path,
167
177
  observations, overrides: overrides.overrides,
178
+ // Declared normative requirements, so an adapter named in `satisfiedBy`
179
+ // can derive the implementation side on the same key.
180
+ requirements: normative.requirements,
168
181
  edges, events, externals, capabilities, verification,
169
182
  errors: [],
170
183
  unitByComponent: new Map(),
@@ -436,6 +449,21 @@ export function compile(root, opts = {}) {
436
449
  }
437
450
  }
438
451
 
452
+ // A requirement is an AUTHORED claim that the implementation must make true.
453
+ // It is a fact like any other so the authority engine compares it with the
454
+ // implementation evidence on the same key — and it carries its citation, so
455
+ // every report can say which section of which document it came from.
456
+ for (const r of normative.requirements) {
457
+ facts.add({
458
+ subject: r.subject, key: r.key, value: r.value, evidenceClass: "AUTHORED", confidence: "declared",
459
+ provRecs: [
460
+ prov.add("declared", r.file, "requirement-extractor", ["ass:pending"]),
461
+ prov.add("declared", r.source.path, "requirement-extractor", ["ass:pending"]),
462
+ ],
463
+ semantics: NORMATIVE_SEMANTICS_PREFIX + r.source.path + "#" + r.source.section,
464
+ });
465
+ }
466
+
439
467
  // ── reviewed overrides → relations and facts ─────────────────────────────
440
468
  const overrideEdges = [];
441
469
  for (const o of overrides.overrides) {
@@ -503,6 +531,13 @@ export function compile(root, opts = {}) {
503
531
  evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs, semantics: o.reason,
504
532
  review: reviewed(o),
505
533
  });
534
+ } else if (o.fact === "requirement-satisfaction") {
535
+ facts.add({
536
+ subject: o.subject, key: o.key, value: o.value,
537
+ evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs,
538
+ semantics: o.reason,
539
+ review: reviewed(o),
540
+ });
506
541
  } else if (o.fact === "component-placement") {
507
542
  const key = o.system ? "placement.system" : "placement.domain";
508
543
  facts.add({
@@ -557,6 +592,7 @@ export function compile(root, opts = {}) {
557
592
 
558
593
  const { conflicts: rawConflicts } = resolveAssertions(facts.facts, authority);
559
594
  pushConflictDiagnostics(rawConflicts, diagnostics, facts);
595
+ pushNormativeDiagnostics(normative, facts, adapters, diagnostics);
560
596
 
561
597
  // ── relations ────────────────────────────────────────────────────────────
562
598
  const rel = buildRelations(ctx);
@@ -753,7 +789,13 @@ function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
753
789
  .filter(Boolean)
754
790
  .map((f) => f.evidenceClass + "=" + JSON.stringify(f.value))
755
791
  .sort();
756
- const detail = c.subject + " " + c.key + ": " + values.join(" vs ");
792
+ let detail = c.subject + " " + c.key + ": " + values.join(" vs ");
793
+ // For a requirement, say where each side comes from: the cited section and
794
+ // what the extractor actually found. Two bare values send a reader hunting.
795
+ if (isRequirementKey(c.key)) {
796
+ const why = c.assertionIds.map((id) => byId.get(id)).filter((f) => f && f.semantics).map((f) => f.semantics).sort();
797
+ if (why.length) detail += " (" + why.join("; ") + ")";
798
+ }
757
799
  if (c.kind === "ambiguity" && c.status === "unresolved") {
758
800
  diagnostics.push({
759
801
  severity: "warning",
@@ -775,7 +817,7 @@ function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
775
817
  message:
776
818
  "unresolved " + c.kind + " — " + detail + ". " +
777
819
  (c.election.rationale || "no authority rule governs this fact") +
778
- ". Add an authority rule and, if deterministic discovery cannot settle it, a reviewed override.",
820
+ (isRequirementKey(c.key) ? "." : ". Add an authority rule and, if deterministic discovery cannot settle it, a reviewed override."),
779
821
  });
780
822
  } else {
781
823
  diagnostics.push({
@@ -794,6 +836,101 @@ function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
794
836
  }
795
837
  }
796
838
 
839
+ // Normative documents, the requirements components declare against them, and
840
+ // which sections nobody has accounted for. Every citation is re-verified
841
+ // against the section's current digest: a requirement or an attestation
842
+ // written against an older wording of the specification is a review that has
843
+ // not happened yet.
844
+ function loadNormative(repo, cfg, sources, overrides) {
845
+ const errors = [];
846
+ const out = { errors, sections: [], uncovered: [], requirements: [] };
847
+ let docs;
848
+ try {
849
+ docs = loadNormativeDocuments(repo, cfg);
850
+ } catch (e) {
851
+ if (!(e instanceof AgentDocError)) throw e;
852
+ errors.push(e);
853
+ return out;
854
+ }
855
+ for (const e of sources.entities) {
856
+ if (e.kind !== "Component") continue;
857
+ const seen = new Set();
858
+ for (const r of e.doc.spec.requirements || []) {
859
+ collect(() => {
860
+ if (seen.has(r.id) || seen.has(r.key)) {
861
+ throw new AgentDocError(CODES.CONFIG, "duplicate requirement id or key on " + e.ref + ": " + r.id + " / " + r.key, { path: e.file, ref: e.ref });
862
+ }
863
+ seen.add(r.id);
864
+ seen.add(r.key);
865
+ verifyCitation(docs, r.source, e.file, e.ref);
866
+ out.requirements.push({ ...r, subject: e.ref, file: e.file, satisfiedBy: r.satisfiedBy || [] });
867
+ }, errors);
868
+ }
869
+ }
870
+ // A satisfaction override must answer a requirement someone declared. One
871
+ // that answers nothing would be an implementation claim with no obligation
872
+ // behind it, which no report could ever surface.
873
+ const declared = new Set(out.requirements.map((r) => r.subject + "\u0000" + r.key));
874
+ for (const o of overrides) {
875
+ if (o.fact !== "requirement-satisfaction") continue;
876
+ if (!declared.has(o.subject + "\u0000" + o.key)) {
877
+ errors.push(new AgentDocError(
878
+ CODES.CONFIG,
879
+ "requirement-satisfaction override answers no declared requirement: " + o.subject + " " + o.key,
880
+ { path: CONFIG_PATH, ref: o.subject }
881
+ ));
882
+ }
883
+ }
884
+ const cov = normativeCoverage(docs, out.requirements, cfg.normativeCoverage || [], errors);
885
+ out.sections = cov.sections;
886
+ out.uncovered = cov.uncovered;
887
+ return out;
888
+ }
889
+
890
+ function pushNormativeDiagnostics(normative, facts, adapters, diagnostics) {
891
+ for (const s of normative.uncovered) {
892
+ const id = s.path + "#" + s.section;
893
+ diagnostics.push({
894
+ severity: "warning",
895
+ code: CODES.NORMATIVE_UNCOVERED,
896
+ subject: id,
897
+ paths: [s.path],
898
+ refs: [],
899
+ message:
900
+ "normative section " + id + " (sha256 " + s.sha256 + ") is routed but not reviewed: " +
901
+ "declare the requirement it places on a component (spec.requirements), or attest in normativeCoverage " +
902
+ "that it carries no implementation obligation.",
903
+ });
904
+ }
905
+ const enabled = new Set(adapters.map((a) => a.name));
906
+ for (const r of normative.requirements) {
907
+ const group = facts.facts.filter((f) => f.subject === r.subject && f.key === r.key && f.status !== "superseded");
908
+ if (group.some((f) => !isNormative(f) && f.confidence !== "candidate")) continue;
909
+ const candidate = group.find((f) => !isNormative(f));
910
+ const missing = r.satisfiedBy.filter((a) => !enabled.has(a));
911
+ const why = candidate
912
+ ? "only a heuristic candidate exists, which is never elected — " + (candidate.semantics || JSON.stringify(candidate.value))
913
+ : !r.satisfiedBy.length
914
+ ? "no adapter is named as able to derive it"
915
+ : missing.length
916
+ ? "the adapter able to derive it is not enabled or not available: " + missing.join(", ")
917
+ : !r.expects
918
+ ? "the requirement has no `expects`, so " + r.satisfiedBy.join(", ") + " has nothing to look for"
919
+ : "the enabled adapter (" + r.satisfiedBy.join(", ") + ") derived no evidence for it";
920
+ diagnostics.push({
921
+ severity: "warning",
922
+ code: CODES.REQUIREMENT_UNVERIFIED,
923
+ subject: r.subject + "#" + r.key,
924
+ paths: [r.source.path],
925
+ refs: [r.subject],
926
+ message:
927
+ "requirement " + r.key + " = " + r.value + " on " + r.subject + " (from " + r.source.path + "#" + r.source.section +
928
+ ") has no implementation evidence; " + why + ". Add a requirement-satisfaction reviewed override citing the " +
929
+ "implementation, or accept the gap with review.",
930
+ });
931
+ }
932
+ }
933
+
797
934
  // A Go import is either host.tld/owner/repo/path or host.tld/owner/repo. The
798
935
  // second form is what a Go *workspace* member uses, and it has only two dots —
799
936
  // a pattern that requires three silently loses every workspace-only dependency.
@@ -15,7 +15,7 @@ import fs from "node:fs";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createHash } from "node:crypto";
17
17
  import { AgentDocError, CODES, collect } from "./codes.mjs";
18
- import { assertRepoPath, expandGlob, sha256Hex } from "./fsx.mjs";
18
+ import { assertGlob, assertRepoPath, expandGlob, sha256Hex } from "./fsx.mjs";
19
19
  import { parseYamlDocuments } from "./yaml.mjs";
20
20
  import { scanForSecrets } from "./secrets.mjs";
21
21
  import { formatErrors } from "./jsonschema.mjs";
@@ -160,10 +160,13 @@ export function loadConfig(repo, bundle) {
160
160
  cfg.output.graph, cfg.output.observations,
161
161
  ...(cfg.discovery.supplementalRoots || []),
162
162
  ...(cfg.discovery.contractRoots || []),
163
- ...(cfg.docs ? Object.values(cfg.docs) : []),
163
+ ...(cfg.docs ? Object.entries(cfg.docs).filter(([k]) => k !== "normative").map(([, v]) => v) : []),
164
164
  ].filter(Boolean)) {
165
165
  assertRepoPath(p, CONFIG_PATH);
166
166
  }
167
+ // `docs.normative` holds globs, not paths: the only role that names a set of
168
+ // documents rather than one.
169
+ for (const g of (cfg.docs && cfg.docs.normative) || []) assertGlob(g, CONFIG_PATH);
167
170
  return cfg;
168
171
  }
169
172
 
package/core/fsx.mjs CHANGED
@@ -175,16 +175,17 @@ export class Repo {
175
175
  return names;
176
176
  }
177
177
 
178
- git(args) {
178
+ git(args, { trim = true } = {}) {
179
179
  // stderr must be piped, not inherited: every caller treats a non-zero exit
180
180
  // as a signal to degrade (unborn HEAD, detached worktree, missing remote),
181
181
  // and execFileSync would otherwise print git's own "fatal:" line to the
182
182
  // user's terminal while the command reports success.
183
- return execFileSync("git", args, {
183
+ const out = execFileSync("git", args, {
184
184
  cwd: this.root,
185
185
  encoding: "utf8",
186
186
  stdio: ["ignore", "pipe", "pipe"],
187
- }).trim();
187
+ });
188
+ return trim ? out.trim() : out;
188
189
  }
189
190
  hasGit() {
190
191
  if (this.noGit) return false;
@@ -239,7 +240,12 @@ export class Repo {
239
240
  if (this.noGit || !this.hasGit()) return [];
240
241
  let raw;
241
242
  try {
242
- raw = this.git(["status", "--porcelain", "-z", "--untracked-files=all"]);
243
+ // Untrimmed: a porcelain record is `XY <path>` with fixed columns, and an
244
+ // unstaged change has X = " ". Trimming would eat that space from the
245
+ // first record, so `.slice(3)` would drop the path's first character —
246
+ // ` M .agentdoc/graph.json` became `agentdoc/graph.json`, slipped past
247
+ // the output exclusion and counted as a dirty input (issue #11).
248
+ raw = this.git(["status", "--porcelain", "-z", "--untracked-files=all"], { trim: false });
243
249
  } catch {
244
250
  return [];
245
251
  }
package/core/graph.mjs CHANGED
@@ -9,7 +9,7 @@ import { assertDeterministic, structuralProjection } from "./determinism.mjs";
9
9
  import { sha256Hex } from "./fsx.mjs";
10
10
 
11
11
  export const COMPILER_NAME = "agentdoc-compiler";
12
- export const COMPILER_VERSION = "1.0.0";
12
+ export const COMPILER_VERSION = "1.1.0";
13
13
  export const GRAPH_SCHEMA_VERSION = "agentdoc.dev/graph/v1";
14
14
 
15
15
  // Resolve symbolic provenance subjects to RFC 6901 pointers into the graph.
@@ -0,0 +1,171 @@
1
+ // Normative requirement coverage.
2
+ //
3
+ // A normative document (a specification, a technical profile) states what the
4
+ // implementation must do. Routing it to a component is not reviewing it: the
5
+ // incident behind this module was a migration that linked every specification,
6
+ // passed every gate, and still never noticed that a "must be reproduced on the
7
+ // production host" had no implementation at all.
8
+ //
9
+ // This module does not read prose for meaning. It does two deterministic
10
+ // things:
11
+ //
12
+ // - it cuts every configured normative document into sections and pins each
13
+ // one by content hash, so "which parts of the specification were reviewed"
14
+ // becomes a question with an exact answer;
15
+ // - it requires every section to be accounted for, either by a structured
16
+ // requirement (a fact the authority engine can compare against evidence)
17
+ // or by an explicit, reviewed attestation that it carries no
18
+ // implementation obligation.
19
+ //
20
+ // Deciding *what* a section obliges is a human act. Making sure that act
21
+ // happened, and is re-done when the section changes, is the machine's.
22
+ import { AgentDocError, CODES } from "./codes.mjs";
23
+ import { expandGlob, sha256Hex } from "./fsx.mjs";
24
+ import { CONFIG_PATH } from "./descriptors.mjs";
25
+
26
+ export const REQUIREMENT_KEY_PREFIX = "requirement.";
27
+ export const NORMATIVE_SEMANTICS_PREFIX = "normative: ";
28
+
29
+ // Sections are the level-2 headings of a Markdown document. Level 1 is the
30
+ // document title; deeper levels belong to the section that contains them, so a
31
+ // reviewer attests a unit that is large enough to carry one obligation and
32
+ // small enough to be re-reviewed when it changes. Headings inside fenced code
33
+ // blocks are not headings.
34
+ export function splitSections(text) {
35
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
36
+ const sections = [];
37
+ const slugs = new Map();
38
+ let cur = null;
39
+ let fence = null;
40
+ for (const line of lines) {
41
+ const f = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
42
+ if (f) {
43
+ if (!fence) fence = f[1][0];
44
+ else if (f[1][0] === fence) fence = null;
45
+ }
46
+ const h = !fence && /^##(?!#)\s+(.+?)\s*#*\s*$/.exec(line);
47
+ if (h) {
48
+ if (cur) sections.push(cur);
49
+ const base = slugify(h[1]);
50
+ // Duplicate headings get the same suffixes a Markdown renderer gives
51
+ // their anchors, so a citation can be the link a reader would follow.
52
+ const n = slugs.get(base) || 0;
53
+ slugs.set(base, n + 1);
54
+ cur = { section: n ? base + "-" + n : base, heading: h[1], lines: [line] };
55
+ continue;
56
+ }
57
+ if (/^#(?!#)\s/.test(line) && !fence) {
58
+ if (cur) sections.push(cur);
59
+ cur = null;
60
+ continue;
61
+ }
62
+ if (cur) cur.lines.push(line);
63
+ }
64
+ if (cur) sections.push(cur);
65
+ return sections.map((s) => {
66
+ // Trailing blank lines are layout, not content: appending a section must
67
+ // not invalidate the review of the one before it.
68
+ while (s.lines.length > 1 && s.lines[s.lines.length - 1].trim() === "") s.lines.pop();
69
+ const body = s.lines.join("\n") + "\n";
70
+ return { section: s.section, heading: s.heading, sha256: sha256Hex(Buffer.from(body, "utf8")) };
71
+ });
72
+ }
73
+
74
+ export function slugify(heading) {
75
+ return String(heading)
76
+ .trim()
77
+ .toLowerCase()
78
+ .replace(/[`*_~[\]()]/g, "")
79
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
80
+ .replace(/\s/g, "-");
81
+ }
82
+
83
+ // Every configured normative document, cut into sections. Reading through the
84
+ // repository handle keeps these files in the compile's input hash, so a change
85
+ // to a specification makes the committed graph stale like any other input.
86
+ export function loadNormativeDocuments(repo, cfg) {
87
+ const globs = (cfg.docs && cfg.docs.normative) || [];
88
+ const paths = new Set();
89
+ for (const g of globs) for (const p of expandGlob(repo, g)) paths.add(p);
90
+ const docs = [];
91
+ for (const p of [...paths].sort()) {
92
+ if (!/\.(md|mdx)$/.test(p)) continue;
93
+ docs.push({ path: p, sections: splitSections(repo.readText(p)) });
94
+ }
95
+ return docs;
96
+ }
97
+
98
+ // A citation of one section: a requirement's `source`, or a coverage
99
+ // attestation. Both must name a section that exists *now*, with the digest it
100
+ // had when someone reviewed it.
101
+ export function verifyCitation(docs, cite, where, ref) {
102
+ const doc = docs.find((d) => d.path === cite.path);
103
+ if (!doc) {
104
+ throw new AgentDocError(
105
+ CODES.CONFIG,
106
+ "cites " + cite.path + ", which is not a configured normative document (docs.normative)",
107
+ { path: where, ref }
108
+ );
109
+ }
110
+ const s = doc.sections.find((x) => x.section === cite.section);
111
+ if (!s) {
112
+ throw new AgentDocError(
113
+ CODES.NORMATIVE_SOURCE_STALE,
114
+ "cited section " + cite.path + "#" + cite.section + " no longer exists — re-review what replaced it",
115
+ { path: where, ref }
116
+ );
117
+ }
118
+ if (s.sha256 !== cite.sha256) {
119
+ throw new AgentDocError(
120
+ CODES.NORMATIVE_SOURCE_STALE,
121
+ "cited section " + cite.path + "#" + cite.section + " changed since it was reviewed — " +
122
+ "re-review it before updating the digest to " + s.sha256,
123
+ { path: where, ref }
124
+ );
125
+ }
126
+ return s;
127
+ }
128
+
129
+ // Coverage: which sections are cited by a requirement, which are attested, and
130
+ // which nobody has looked at. Returned rather than pushed so the audit can show
131
+ // the same table the gate enforces.
132
+ export function normativeCoverage(docs, requirements, attestations, errors) {
133
+ const coveredBy = new Map();
134
+ const mark = (path, section, by) => {
135
+ const k = path + "#" + section;
136
+ if (!coveredBy.has(k)) coveredBy.set(k, []);
137
+ coveredBy.get(k).push(by);
138
+ };
139
+ for (const r of requirements) {
140
+ mark(r.source.path, r.source.section, "requirement:" + r.subject + " " + r.key);
141
+ }
142
+ const seen = new Set();
143
+ for (const a of attestations) {
144
+ const k = a.path + "#" + a.section;
145
+ try {
146
+ if (seen.has(k)) throw new AgentDocError(CODES.CONFIG, "duplicate normativeCoverage entry for " + k, { path: CONFIG_PATH });
147
+ seen.add(k);
148
+ verifyCitation(docs, a, CONFIG_PATH, null);
149
+ mark(a.path, a.section, "attestation:" + a.disposition);
150
+ } catch (e) {
151
+ if (!(e instanceof AgentDocError)) throw e;
152
+ errors.push(e);
153
+ }
154
+ }
155
+ const sections = [];
156
+ for (const d of docs) {
157
+ for (const s of d.sections) {
158
+ const by = (coveredBy.get(d.path + "#" + s.section) || []).sort();
159
+ sections.push({ path: d.path, section: s.section, heading: s.heading, sha256: s.sha256, coveredBy: by });
160
+ }
161
+ }
162
+ return { sections, uncovered: sections.filter((s) => !s.coveredBy.length) };
163
+ }
164
+
165
+ export function isRequirementKey(key) {
166
+ return typeof key === "string" && key.startsWith(REQUIREMENT_KEY_PREFIX);
167
+ }
168
+
169
+ export function isNormative(assertion) {
170
+ return typeof assertion.semantics === "string" && assertion.semantics.startsWith(NORMATIVE_SEMANTICS_PREFIX);
171
+ }
package/core/yaml.mjs CHANGED
@@ -425,8 +425,10 @@ class Reader {
425
425
  }
426
426
 
427
427
  // Parse a multi-document YAML stream. Returns [{doc, line}] with 1-based line
428
- // numbers of each document root.
429
- export function parseYamlDocuments(src, path) {
428
+ // numbers of each document root. Catalog sources must be mappings; a reader of
429
+ // third-party files whose root is a list (an Ansible playbook or task file)
430
+ // opts in with `allowSequenceRoot`.
431
+ export function parseYamlDocuments(src, path, { allowSequenceRoot = false } = {}) {
430
432
  if (typeof src !== "string") throw new AgentDocError(CODES.UTF8, "expected text for " + path, { path });
431
433
  if (src.charCodeAt(0) === 0xfeff) src = src.slice(1);
432
434
  scanBanned(src, path);
@@ -458,7 +460,7 @@ export function parseYamlDocuments(src, path) {
458
460
  throw new AgentDocError(CODES.YAML_PARSE, "unparsed trailing content", { path, line: r.peek().lineNo });
459
461
  }
460
462
  if (value === null) continue;
461
- if (typeof value !== "object" || Array.isArray(value)) {
463
+ if (typeof value !== "object" || (Array.isArray(value) && !allowSequenceRoot)) {
462
464
  throw new AgentDocError(CODES.YAML_NON_MAP_ROOT, "document root must be a mapping", { path, line: rootLine });
463
465
  }
464
466
  out.push({ doc: value, line: rootLine });
package/docs/ADAPTERS.md CHANGED
@@ -43,6 +43,7 @@ Available on `ctx`:
43
43
  | `transitiveDependencies(ref)` | manifest-declared dependencies, order-independent |
44
44
  | `productionDependencies(ref)` | production dependency names, for client-of-resource rules |
45
45
  | `deferSecondPass(fn)` | run after every unit is known |
46
+ | `requirements` | declared normative requirements (`subject`, `key`, `value`, `source`, `satisfiedBy`, `expects`) an adapter may derive the implementation side of |
46
47
  | `unitByPkgName`, `unitByGoModule` | cross-unit resolution built before extraction |
47
48
  | `errors.push({ code, message })` | a hard error, collected rather than thrown |
48
49
 
@@ -92,7 +93,7 @@ cannot name the file a fact came from, you cannot add the fact.
92
93
  | `openapi` | `*.openapi.y[a]ml` | operation inventory, deterministic consumers |
93
94
  | `protobuf` / `graphql` | contract files | method/type inventory, consumers |
94
95
  | `oidc` | discovery-path literals | OIDC client consumers |
95
- | `github-actions` | `.github/workflows` | component-scoped checks and repository gates |
96
+ | `github-actions` | repository-root `.github/workflows` (not a unit marker; a nested one is ignored, as GitHub ignores it) | component-scoped checks and repository gates |
96
97
  | `dockerfile` | `Dockerfile`, `Containerfile` | deployable evidence |
97
98
  | `deploy-config` | `fly.toml`, `Procfile`, … | deployable evidence |
98
99
  | `wrangler` | `wrangler.toml/json` | bindings, crons, service bindings, trigger targets |
@@ -100,10 +101,44 @@ cannot name the file a fact came from, you cannot add the fact.
100
101
  | `object-store` | bucket literals | logical-dataset edges, or an ambiguity |
101
102
  | `cache` | cache client evidence | cache bindings |
102
103
  | `events` | subject literals, declared constants, event-contract files | producers, consumers, reserved subjects |
104
+ | `ansible` | playbooks (YAML lists of plays) | deployable evidence, installed paths and managed services, the DERIVED side of provisioning requirements |
103
105
 
104
106
  `generic` is always on. Everything else is opt-in through `adapters: [...]`, and
105
107
  an unknown name is `AGENTDOC_CONFIG`, not a silent no-op.
106
108
 
109
+ ### `ansible`
110
+
111
+ Reads playbooks under a unit root and follows what they reach — plays,
112
+ `roles:`, `include_role`/`import_role`, `include_tasks`/`import_tasks`,
113
+ `import_playbook`, `block`/`rescue`/`always` — inside the repository only.
114
+ Nothing is executed and no Jinja is evaluated.
115
+
116
+ | it derives | from |
117
+ |---|---|
118
+ | an installed path | `copy`/`template`/`get_url`/`git`/`synchronize` `dest`, `unarchive` `dest`/`creates`, `pip` `virtualenv`, `file` with `state: directory\|touch\|link\|hard`, `lineinfile`/`blockinfile` with `create: true`, a command's literal `creates` |
119
+ | a managed service | `systemd`, `systemd_service`, `service` `name` |
120
+
121
+ The inventory lands on the component as `derived.provisioning`. For a normative
122
+ requirement on that component with `satisfiedBy: [ansible]` and an `expects`
123
+ (`installs: <path>` or `service: <name>`), it adds a `DERIVED` fact on the
124
+ requirement key:
125
+
126
+ | value | confidence | when |
127
+ |---|---|---|
128
+ | `provided` | deterministic | a reachable task without `when` installs the path (or a path below it), or manages the service |
129
+ | `provided` | candidate | the only matching task is conditional (`when`, `rescue`, a conditional role or include) |
130
+ | `absent` | deterministic | no reachable task matches **and** every reachable task is transparent |
131
+ | `absent` | candidate | no task matches, but something opaque was reached |
132
+
133
+ **What it cannot see, and therefore never rules out:** a command
134
+ (`shell`/`command`/`script`/`raw`) without a literal `creates`; a module it does
135
+ not model (`apt`, `dnf`, `package`, collections, …); a templated path or a
136
+ `loop`; a role, include or imported playbook that is not in the repository or is
137
+ templated; a file that does not parse. Any of these makes an absence a
138
+ candidate, which is never elected — the requirement stays
139
+ `AGENTDOC_REQUIREMENT_UNVERIFIED` and the diagnostic names what was opaque.
140
+ Inventory files, `group_vars`/`host_vars` and variables are not read.
141
+
107
142
  `asyncapi` is a contract **type** (declared on an API descriptor, validated by
108
143
  the contract layer), not an adapter — there is no `asyncapi` entry in
109
144
  `adapters: [...]`.
package/docs/AUTHORITY.md CHANGED
@@ -175,7 +175,16 @@ For a group of assertions on one `(subject, key)`:
175
175
  - `recency` — only when the rule names `OBSERVED_RUNTIME` and more than one
176
176
  dated observation exists. The newest wins, and the rule is cited.
177
177
  - otherwise `basis: none`, status `unresolved`, and an **error**.
178
- 3. Every non-elected assertion is kept with `status: contradicted` (or
178
+ 3. **Normative requirements are never elected on their own word.** An
179
+ assertion from a component's `spec.requirements` (`requirement.*` keys) is a
180
+ claim about what the implementation *must* do:
181
+ - alone on its key it stays `unresolved` (`AGENTDOC_REQUIREMENT_UNVERIFIED`);
182
+ - with agreeing implementation evidence it is corroborated and `elected`;
183
+ - with disagreeing evidence **no basis is attempted** — not even a
184
+ `reviewed-override` rule. Electing the implementation would waive the
185
+ specification; electing the requirement would hide the missing
186
+ implementation. The conflict is an error until one of them changes.
187
+ 4. Every non-elected assertion is kept with `status: contradicted` (or
179
188
  `candidate`). **The contradicted value stays in the graph.** A system that
180
189
  discards the repository's own claim cannot explain the divergence to a human.
181
190
 
package/docs/CI.md CHANGED
@@ -20,13 +20,15 @@ jobs:
20
20
  with: { fetch-depth: 0 }
21
21
  - uses: actions/setup-node@v4
22
22
  with: { node-version: '24' }
23
- # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.0.0 —
23
+ # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.1.0 —
24
24
  # a compiler upgrade intentionally invalidates every committed graph.
25
25
  - run: npm install -g github:lbframe/agent-doc-system
26
26
  - run: agentdoc validate
27
27
  - run: agentdoc compile
28
28
  - run: agentdoc check
29
29
  - run: agentdoc check --require-clean
30
+ # Once MIGRATE is done: no unaccepted warning may remain.
31
+ - run: agentdoc check --strict
30
32
  - run: agentdoc eval routing
31
33
  ```
32
34