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/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
|
-
|
|
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.
|
package/core/descriptors.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
183
|
+
const out = execFileSync("git", args, {
|
|
184
184
|
cwd: this.root,
|
|
185
185
|
encoding: "utf8",
|
|
186
186
|
stdio: ["ignore", "pipe", "pipe"],
|
|
187
|
-
})
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|