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/bin/agentdoc.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  //
4
4
  // The command list lives in bin/usage.txt and is printed from there, so this
5
5
  // header, `--help` and the shipped file cannot drift into three different
6
- // claims about what the tool does.
6
+ // claims about what the tool does. The flags each command accepts live in
7
+ // bin/commands.mjs, which drives both parsing and `agentdoc <command> --help`.
7
8
  import fs from "node:fs";
8
9
  import path from "node:path";
9
10
  import { fileURLToPath } from "node:url";
@@ -17,6 +18,7 @@ import { audit, scaffoldProposal } from "../core/audit.mjs";
17
18
  import { observationFreshness, stalenessDiagnostics } from "../core/observations.mjs";
18
19
  import { assertNoSecretsInText } from "../core/secrets.mjs";
19
20
  import { installTemplates } from "../core/scaffold.mjs";
21
+ import { COMMANDS, flagsOf, parseArgs, commandHelp, UsageError } from "./commands.mjs";
20
22
  import { loadSchemaBundle, loadConfig } from "../core/descriptors.mjs";
21
23
  import { execFileSync } from "node:child_process";
22
24
 
@@ -41,36 +43,27 @@ function findRoot(start) {
41
43
  const ROOT = findRoot(process.cwd());
42
44
  const argv = process.argv.slice(2);
43
45
  const cmd = argv[0];
44
- // Flags that consume the next token as a value. Anything else is boolean; a
45
- // `--flag value` pair must not leak the value into `positional`
46
- // (`agentdoc query --budget relations=10 svc/api` must query svc/api).
47
- const VALUE_FLAGS = new Set(["budget", "diff", "from"]);
48
- const flags = new Set();
49
- const positional = [];
50
- for (let i = 1; i < argv.length; i++) {
51
- const a = argv[i];
52
- if (a.startsWith("--")) {
53
- const name = a.replace(/^--/, "").split("=")[0];
54
- if (a.includes("=") && !VALUE_FLAGS.has(name)) {
55
- console.error("agentdoc: --" + name + " does not take a value");
56
- process.exit(2);
57
- }
58
- flags.add(name);
59
- if (!a.includes("=") && VALUE_FLAGS.has(name) && argv[i + 1] && !argv[i + 1].startsWith("--")) i++;
60
- continue;
46
+ // Flags are declared per command in bin/commands.mjs. An undeclared flag is a
47
+ // usage error (exit 2), so a typo in a gate flag can never pass for the gate.
48
+ let parsed = { flags: new Map(), positional: [] };
49
+ if (Object.prototype.hasOwnProperty.call(COMMANDS, cmd)) {
50
+ try {
51
+ parsed = parseArgs(cmd, argv.slice(1));
52
+ } catch (e) {
53
+ if (!(e instanceof UsageError)) throw e;
54
+ console.error(e.message);
55
+ process.exit(2);
61
56
  }
62
- positional.push(a);
63
57
  }
64
- const flag = (name, dflt = null) => {
65
- for (let i = 0; i < argv.length; i++) {
66
- if (argv[i] === "--" + name) {
67
- const next = argv[i + 1];
68
- return next && !next.startsWith("--") ? next : true;
69
- }
70
- if (argv[i].startsWith("--" + name + "=")) return argv[i].slice(name.length + 3);
58
+ const positional = parsed.positional;
59
+ // The only way to read a flag. Reading one the command does not declare is a
60
+ // defect in this file, not user input: it would be a flag `--help` never shows.
61
+ function opt(name) {
62
+ if (!Object.prototype.hasOwnProperty.call(flagsOf(cmd), name)) {
63
+ throw new Error("flag --" + name + " is read by '" + cmd + "' but not declared for it in bin/commands.mjs");
71
64
  }
72
- return dflt;
73
- };
65
+ return parsed.flags.has(name) ? parsed.flags.get(name) : null;
66
+ }
74
67
 
75
68
  function fail(errors, stats) {
76
69
  for (const e of errors) console.error("ERROR " + (e.format ? e.format() : e.code + ": " + e.message));
@@ -103,7 +96,7 @@ function gateObservationFreshness(res) {
103
96
  const fresh = observationFreshness(graph.observations || [], new Date());
104
97
  const stale = stalenessDiagnostics(fresh);
105
98
  if (!stale.length) return fresh;
106
- if (!flags.has("allow-stale-observations")) {
99
+ if (!opt("allow-stale-observations")) {
107
100
  for (const d of stale) console.error("ERROR " + d.code + ": " + d.message);
108
101
  process.exit(1);
109
102
  }
@@ -160,7 +153,7 @@ function requireFresh(res, fresh) {
160
153
  } catch (e) {
161
154
  fail([e]);
162
155
  }
163
- if (graph.source.dirty && (flags.has("require-clean") || flag("require-clean"))) {
156
+ if (graph.source.dirty && opt("require-clean")) {
164
157
  fail([new AgentDocError(
165
158
  CODES.DIRTY_SOURCE,
166
159
  "catalog inputs differ from HEAD (" + res.stats.commit + ") — CI/publish evidence requires a clean source; commit the change and rebuild"
@@ -183,9 +176,18 @@ try {
183
176
  console.log("agentdoc " + VERSION);
184
177
  process.exit(0);
185
178
  }
186
- if (cmd === "--help" || cmd === "-h" || cmd === "help" || flags.has("help") || argv.includes("-h")) {
179
+ if (cmd === "--help" || cmd === "-h" || cmd === "help") {
180
+ // `agentdoc help <command>` is the same as `agentdoc <command> --help`.
181
+ if (Object.prototype.hasOwnProperty.call(COMMANDS, argv[1])) {
182
+ process.stdout.write(commandHelp(argv[1]));
183
+ process.exit(0);
184
+ }
187
185
  usage(0);
188
186
  }
187
+ if (Object.prototype.hasOwnProperty.call(COMMANDS, cmd) && opt("help")) {
188
+ process.stdout.write(commandHelp(cmd));
189
+ process.exit(0);
190
+ }
189
191
  if (cmd === "validate") {
190
192
  const res = compile(ROOT);
191
193
  printDiagnostics(res);
@@ -195,7 +197,7 @@ try {
195
197
  const res = compile(ROOT);
196
198
  printDiagnostics(res);
197
199
  if (res.errors.length) fail(res.errors, res.stats);
198
- if ((flags.has("require-clean") || flag("require-clean")) && res.stats.dirty) {
200
+ if (opt("require-clean") && res.stats.dirty) {
199
201
  fail([new AgentDocError(CODES.DIRTY_SOURCE, "catalog inputs differ from HEAD — commit the change and rebuild")], res.stats);
200
202
  }
201
203
  fs.mkdirSync(path.dirname(path.join(ROOT, res.graphPath)), { recursive: true });
@@ -227,7 +229,21 @@ try {
227
229
  if (gj !== text) {
228
230
  fail([new AgentDocError(CODES.STALE_GRAPH, "compiled graph is not canonically serialized — rebuild the graph")]);
229
231
  }
230
- console.log("agentdoc check: PASS " + JSON.stringify({ ...res.stats, observationFreshness: fresh.length }));
232
+ // `--strict` is the migration gate: the graph is current *and* nothing is
233
+ // left for a human. Every warning is either accepted with pinned evidence
234
+ // or the gate fails, so a normative section nobody reviewed or a
235
+ // requirement nothing proves cannot ride along in a green check. Opt-in, so
236
+ // plain `check` keeps meaning "the committed graph is fresh".
237
+ if (opt("strict")) {
238
+ const open = (res.diagnostics || []).filter((d) => d.severity === "warning" && !d.acceptance);
239
+ if (open.length) {
240
+ fail(
241
+ open.map((d) => new AgentDocError(d.code, (d.subject && !d.message.includes(d.subject) ? d.subject + ": " : "") + d.message)),
242
+ { ...res.stats, strict: true }
243
+ );
244
+ }
245
+ }
246
+ console.log("agentdoc check: PASS " + JSON.stringify({ ...res.stats, observationFreshness: fresh.length, ...(opt("strict") ? { strict: true } : {}) }));
231
247
  } else if (cmd === "query") {
232
248
  const needle = positional[0];
233
249
  if (!needle) { console.error("usage: agentdoc query <repo-path|entity-ref> [--json] [--md] [--budget relations=10,verification=4]"); process.exit(2); }
@@ -243,7 +259,7 @@ try {
243
259
  // untestable and unreachable from the CLI. An agent working in a tight
244
260
  // context needs to be able to say so.
245
261
  let budget = {};
246
- const raw = flag("budget");
262
+ const raw = opt("budget");
247
263
  if (raw === true || raw === "") {
248
264
  console.error("usage: --budget section=limit,... (limits are positive integers)");
249
265
  process.exit(2);
@@ -259,8 +275,8 @@ try {
259
275
  }
260
276
  }
261
277
  const ctx = queryContext(graph, needle, { budget });
262
- if (flags.has("md") || flag("md")) out(renderContext(ctx));
263
- else if (flags.has("json")) out(ctx, true);
278
+ if (opt("md")) out(renderContext(ctx));
279
+ else if (opt("json")) out(ctx, true);
264
280
  else out(renderContext(ctx));
265
281
  } else if (cmd === "impact") {
266
282
  // Impact answers a question about the working tree, so it compiles in
@@ -270,7 +286,7 @@ try {
270
286
  const res = compile(ROOT);
271
287
  if (res.errors.length) fail(res.errors, res.stats);
272
288
  const graph = res.graph;
273
- const fromRef = flag("diff") || flag("from");
289
+ const fromRef = opt("diff") || opt("from");
274
290
  let paths = positional;
275
291
  if (fromRef && fromRef !== true) {
276
292
  if (!res.repo.hasGit()) {
@@ -309,7 +325,7 @@ try {
309
325
  out(report, true);
310
326
  } else if (cmd === "scaffold") {
311
327
  const res = compile(ROOT);
312
- const write = flags.has("write") || flag("write");
328
+ const write = opt("write");
313
329
  // Scaffolding is how a repository reaches a compiling state: requiring the
314
330
  // compile to succeed first deadlocks CREATE on every repository whose units
315
331
  // are discovered by adapters (go.work, pnpm-workspace, supplementalRoots)
@@ -337,7 +353,7 @@ try {
337
353
  : {}),
338
354
  }, true);
339
355
  } else if (cmd === "init") {
340
- const result = installTemplates(null, null, { write: true, root: ROOT, force: flags.has("force") });
356
+ const result = installTemplates(null, null, { write: true, root: ROOT, force: opt("force") });
341
357
  out(result, true);
342
358
  } else if (cmd === "eval") {
343
359
  const which = positional[0] || "routing";
@@ -355,8 +371,8 @@ try {
355
371
  }
356
372
  throw e;
357
373
  }
358
- const report = runRoutingEval(ROOT, { json: flags.has("json") });
359
- if (flags.has("json")) { out(report, true); process.exit(report.verdict === "FAIL" ? 1 : 0); }
374
+ const report = runRoutingEval(ROOT, { json: opt("json") });
375
+ if (opt("json")) { out(report, true); process.exit(report.verdict === "FAIL" ? 1 : 0); }
360
376
  else {
361
377
  console.log(JSON.stringify(report.metrics, null, 2));
362
378
  console.log("thresholds: " + JSON.stringify(report.thresholds));
@@ -390,7 +406,7 @@ try {
390
406
  });
391
407
  }
392
408
  }
393
- if (flags.has("json")) { out({ evidence: results }, true); }
409
+ if (opt("json")) { out({ evidence: results }, true); }
394
410
  else {
395
411
  for (const r of results) {
396
412
  console.log(r.status.padEnd(8) + " " + r.path);
@@ -435,7 +451,7 @@ try {
435
451
  const machineOk = machine.nodeSupported && machine.schemaBundle.ok;
436
452
 
437
453
  const report = { scope: "machine", ok: machineOk, machine };
438
- if (flags.has("project")) {
454
+ if (opt("project")) {
439
455
  const configFound = fs.existsSync(path.join(ROOT, "agentdoc", "agentdoc.config.yaml"));
440
456
  const project = {
441
457
  root: ROOT,
@@ -0,0 +1,169 @@
1
+ // Command and flag declarations.
2
+ //
3
+ // One table drives three things: which flags a command accepts, how they are
4
+ // parsed, and what `agentdoc <command> --help` prints. Parsing and help read
5
+ // the same rows, so they cannot drift. A flag that is not declared for the
6
+ // command it is given to is a usage error, never silently ignored: a
7
+ // misspelled gate flag (`check --strcit`) must not turn into a green,
8
+ // non-strict gate.
9
+
10
+ const JSON_FLAG = { json: { desc: "print the machine-readable report" } };
11
+ // Commands whose output is always JSON accept --json too, as a no-op, so a
12
+ // script that says what it expects is not rejected for being explicit.
13
+ const JSON_ALWAYS = { json: { desc: "accepted for explicitness; the output is always JSON" } };
14
+
15
+ export const COMMANDS = {
16
+ init: {
17
+ synopsis: "agentdoc init [--force]",
18
+ summary: "write a configuration and documentation skeleton",
19
+ flags: { force: { desc: "overwrite files that already exist" }, ...JSON_ALWAYS },
20
+ },
21
+ audit: {
22
+ synopsis: "agentdoc audit",
23
+ summary: "inventory the repository and classify every fact (JSON report)",
24
+ flags: { ...JSON_ALWAYS },
25
+ },
26
+ scaffold: {
27
+ synopsis: "agentdoc scaffold [--write]",
28
+ summary: "generate a CREATE-mode skeleton from the audit (dry run by default)",
29
+ flags: { write: { desc: "write the proposed files instead of listing them" }, ...JSON_ALWAYS },
30
+ },
31
+ validate: {
32
+ synopsis: "agentdoc validate",
33
+ summary: "sources, schemas and semantic checks",
34
+ flags: {},
35
+ },
36
+ compile: {
37
+ synopsis: "agentdoc compile [--require-clean]",
38
+ summary: "deterministic graph build (atomic write)",
39
+ flags: { "require-clean": { desc: "refuse when catalog inputs differ from HEAD" } },
40
+ },
41
+ check: {
42
+ synopsis: "agentdoc check [--strict] [--require-clean] [--allow-stale-observations]",
43
+ summary: "freshness, determinism and gate check",
44
+ flags: {
45
+ strict: { desc: "also fail on every warning not accepted in warningAcceptances (the end-of-MIGRATE gate)" },
46
+ "require-clean": { desc: "refuse a graph built from inputs that differ from HEAD" },
47
+ "allow-stale-observations": { desc: "let observations past maxAgeDays through, applying each rule's degradeOnStale" },
48
+ },
49
+ },
50
+ query: {
51
+ synopsis: "agentdoc query <path|ref> [--md] [--json] [--budget section=n,...]",
52
+ summary: "route minimal context for a task",
53
+ flags: {
54
+ md: { desc: "compact markdown context (the default)" },
55
+ ...JSON_FLAG,
56
+ budget: { value: "section=n,...", desc: "cap sections, e.g. relations=10,verification=4; truncation is reported" },
57
+ "require-clean": { desc: "refuse a graph built from inputs that differ from HEAD" },
58
+ "allow-stale-observations": { desc: "let observations past maxAgeDays through, applying each rule's degradeOnStale" },
59
+ },
60
+ },
61
+ impact: {
62
+ synopsis: "agentdoc impact <paths...> | agentdoc impact --diff <git-ref>",
63
+ summary: "what a change forces you to keep consistent",
64
+ flags: {
65
+ diff: { value: "git-ref", desc: "use the files changed since <git-ref> instead of paths" },
66
+ from: { value: "git-ref", desc: "alias of --diff" },
67
+ ...JSON_ALWAYS,
68
+ },
69
+ },
70
+ eval: {
71
+ synopsis: "agentdoc eval routing [--json]",
72
+ summary: "agent-routing evaluation against registered scenarios (SKIP when the repo has none)",
73
+ flags: { ...JSON_FLAG },
74
+ },
75
+ "pin-evidence": {
76
+ synopsis: "agentdoc pin-evidence [--json]",
77
+ summary: "verify digests pinned by reviewed overrides",
78
+ flags: { ...JSON_FLAG },
79
+ },
80
+ doctor: {
81
+ synopsis: "agentdoc doctor [--project]",
82
+ summary: "machine self-check: is this CLI install healthy",
83
+ flags: { project: { desc: "also check the current project's catalog health" }, ...JSON_ALWAYS },
84
+ },
85
+ };
86
+
87
+ // Accepted by every command.
88
+ const COMMON_FLAGS = { help: { desc: "show this help" } };
89
+
90
+ export function flagsOf(cmd) {
91
+ return { ...(COMMANDS[cmd] ? COMMANDS[cmd].flags : {}), ...COMMON_FLAGS };
92
+ }
93
+
94
+ export class UsageError extends Error {}
95
+
96
+ // Parse argv (without the command) against the command's declaration.
97
+ // Returns { flags: Map(name -> true|string), positional }.
98
+ export function parseArgs(cmd, args) {
99
+ const declared = flagsOf(cmd);
100
+ const flags = new Map();
101
+ const positional = [];
102
+ for (let i = 0; i < args.length; i++) {
103
+ const a = args[i];
104
+ if (a === "-h") { flags.set("help", true); continue; }
105
+ if (!a.startsWith("--")) { positional.push(a); continue; }
106
+ const eq = a.indexOf("=");
107
+ const name = a.slice(2, eq < 0 ? undefined : eq);
108
+ const spec = declared[name];
109
+ if (!spec) throw new UsageError(unknownFlagMessage(cmd, name));
110
+ if (!spec.value) {
111
+ if (eq >= 0) throw new UsageError("agentdoc " + cmd + ": --" + name + " does not take a value");
112
+ flags.set(name, true);
113
+ continue;
114
+ }
115
+ let v = eq >= 0 ? a.slice(eq + 1) : args[i + 1];
116
+ if (eq < 0) {
117
+ if (v === undefined || v.startsWith("--")) v = "";
118
+ else i++;
119
+ }
120
+ if (!v) throw new UsageError("agentdoc " + cmd + ": --" + name + " requires a value (" + spec.value + ")");
121
+ flags.set(name, v);
122
+ }
123
+ return { flags, positional };
124
+ }
125
+
126
+ function unknownFlagMessage(cmd, name) {
127
+ const own = Object.keys(flagsOf(cmd));
128
+ const near = own
129
+ .map((f) => [f, distance(f, name)])
130
+ .filter(([f, d]) => d <= Math.max(1, Math.floor(f.length / 3)))
131
+ .sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : 1))[0];
132
+ const elsewhere = Object.keys(COMMANDS).filter((c) => c !== cmd && COMMANDS[c].flags[name]);
133
+ let hint = "";
134
+ if (near) hint = " (did you mean --" + near[0] + "?)";
135
+ else if (elsewhere.length) hint = " (--" + name + " is accepted by: " + elsewhere.join(", ") + ")";
136
+ return "agentdoc " + cmd + ": unknown flag --" + name + hint +
137
+ "\nrun `agentdoc " + cmd + " --help` for the flags this command accepts";
138
+ }
139
+
140
+ function distance(a, b) {
141
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
142
+ for (let j = 1; j <= b.length; j++) dp[0][j] = j;
143
+ for (let i = 1; i <= a.length; i++) {
144
+ for (let j = 1; j <= b.length; j++) {
145
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
146
+ // Adjacent transposition: `strcit` is one slip away from `strict`.
147
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + 1);
148
+ }
149
+ }
150
+ return dp[a.length][b.length];
151
+ }
152
+
153
+ export function commandHelp(cmd) {
154
+ const c = COMMANDS[cmd];
155
+ const flags = flagsOf(cmd);
156
+ const rows = Object.entries(flags).map(([name, spec]) => ["--" + name + (spec.value ? " <" + spec.value + ">" : ""), spec.desc]);
157
+ const width = Math.max(...rows.map((r) => r[0].length));
158
+ return [
159
+ "usage: " + c.synopsis,
160
+ "",
161
+ c.summary,
162
+ "",
163
+ "flags:",
164
+ ...rows.map(([f, d]) => " " + f.padEnd(width) + " " + d),
165
+ "",
166
+ "Exit codes: 0 ok, 1 failure, 2 usage.",
167
+ "",
168
+ ].join("\n");
169
+ }
package/bin/usage.txt CHANGED
@@ -1,14 +1,15 @@
1
1
  agentdoc — portable documentation & software-catalog system
2
2
 
3
3
  agentdoc --version print the installed version
4
- agentdoc --help show this help (also: agentdoc <command> --help)
4
+ agentdoc --help show this help
5
+ agentdoc <command> --help every flag one command accepts (also: agentdoc help <command>)
5
6
 
6
7
  agentdoc init [--force] write a configuration and documentation skeleton
7
8
  agentdoc audit inventory the repository, classify every fact
8
9
  agentdoc scaffold [--write] generate a CREATE-mode skeleton from the audit
9
10
  agentdoc validate sources + schemas + semantic checks
10
11
  agentdoc compile [--require-clean] deterministic graph build (atomic write)
11
- agentdoc check [--require-clean] [--allow-stale-observations]
12
+ agentdoc check [--strict] [--require-clean] [--allow-stale-observations]
12
13
  freshness, determinism and gate check
13
14
  agentdoc query <path|ref> [--md] [--json] [--budget k=n]
14
15
  route minimal context for a task
@@ -23,4 +24,5 @@ agentdoc — portable documentation & software-catalog system
23
24
  Commands that operate on a catalog run from anywhere inside the repository;
24
25
  the configuration is found by walking up to agentdoc/agentdoc.config.yaml.
25
26
 
26
- Exit codes: 0 ok, 1 failure, 2 usage.
27
+ Exit codes: 0 ok, 1 failure, 2 usage. An unknown flag is a usage error (2),
28
+ never ignored.
package/core/audit.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  import { classifyContractFile } from "./contracts.mjs";
19
19
  import { ContextIndex } from "./query.mjs";
20
20
  import { expandGlob, isRepoPath } from "./fsx.mjs";
21
+ import { isNormative, NORMATIVE_SEMANTICS_PREFIX } from "./normative.mjs";
21
22
 
22
23
  const DOC_EXT = /\.(md|mdx|adoc|rst|txt)$/;
23
24
 
@@ -28,7 +29,11 @@ export function audit(repo, cfg, res) {
28
29
  const note = (fact, cls, why) => {
29
30
  if (!classes.has(fact)) classes.set(fact, { cls, why: [] });
30
31
  const cur = classes.get(fact);
32
+ // Strongest class wins regardless of the order sources are visited in:
33
+ // corroboration outranks a single derivation, and a conflict or an open
34
+ // question outranks both.
31
35
  if (cls === "CONFLICT" || cls === "UNRESOLVED") cur.cls = cls;
36
+ else if (cls === "CONFIRMED" && cur.cls === "DERIVED") cur.cls = cls;
32
37
  if (why) cur.why.push(why);
33
38
  };
34
39
 
@@ -53,8 +58,35 @@ export function audit(repo, cfg, res) {
53
58
  }
54
59
 
55
60
  // 2. facts and conflicts
61
+ const normative = res.normative || { sections: [], uncovered: [], requirements: [] };
62
+ const enabledAdapters = new Set((graph.compiler.adapters || []).map((a) => a.name));
56
63
  for (const a of graph.assertions) {
57
64
  const f = a.subject + " " + a.key;
65
+ if (isNormative(a)) {
66
+ // A requirement names where it came from, and — when nothing proves it —
67
+ // what could have: a missing extractor reduces coverage, it does not
68
+ // make an obligation disappear.
69
+ const cite = a.semantics.slice(NORMATIVE_SEMANTICS_PREFIX.length);
70
+ const all = graph.assertions.filter((b) => b.subject === a.subject && b.key === a.key && b !== a && !isNormative(b));
71
+ const peers = all.filter((b) => b.confidence !== "candidate");
72
+ const candidates = all.filter((b) => b.confidence === "candidate");
73
+ if (!peers.length) {
74
+ const r = normative.requirements.find((x) => x.subject === a.subject && x.key === a.key) || { satisfiedBy: [] };
75
+ const missing = r.satisfiedBy.filter((n) => !enabledAdapters.has(n));
76
+ note(f, "UNRESOLVED", "normative: " + cite + " requires " + JSON.stringify(a.value));
77
+ note(f, "UNRESOLVED",
78
+ "no implementation evidence" +
79
+ (candidates.length ? "; heuristic candidate only: " + (candidates[0].semantics || JSON.stringify(candidates[0].value))
80
+ : missing.length ? "; adapter not enabled: " + missing.join(", ")
81
+ : r.satisfiedBy.length ? "; " + r.satisfiedBy.join(", ") + " derived nothing" : "; no adapter named in satisfiedBy"));
82
+ } else if (a.status === "elected") {
83
+ note(f, "CONFIRMED", "normative: " + cite + " corroborated by " + peers.map((b) => b.evidenceClass).join(", "));
84
+ } else {
85
+ note(f, "CONFLICT", "normative: " + cite + " requires " + JSON.stringify(a.value) + "; implementation evidence says " +
86
+ peers.map((b) => b.evidenceClass + "=" + JSON.stringify(b.value)).join(", "));
87
+ }
88
+ continue;
89
+ }
58
90
  if (a.evidenceClass === "OBSERVED_RUNTIME") note(f, "OBSERVED", a.observed ? a.observed.sourceSystem + " at " + a.observed.at : "runtime");
59
91
  else if (a.status === "unresolved") note(f, "UNRESOLVED", "no deterministic resolution");
60
92
  else if (a.status === "candidate") note(f, "UNRESOLVED", "heuristic candidate only");
@@ -96,7 +128,7 @@ export function audit(repo, cfg, res) {
96
128
  // loop below; letting one through as a "document" makes `exists: true` for a
97
129
  // directory, which is a check that can never fail.
98
130
  const configured = Object.entries(cfg.docs || {})
99
- .filter(([role]) => !role.endsWith("Dir"))
131
+ .filter(([role]) => !role.endsWith("Dir") && role !== "normative")
100
132
  .map(([role, p]) => ({ role, path: p, configured: true }));
101
133
  // Directory-shaped roles are enumerated rather than listed as a single entry:
102
134
  // a directory is not a document, and reporting it as one is a dead check.
@@ -303,6 +335,13 @@ export function audit(repo, cfg, res) {
303
335
  }
304
336
  }
305
337
 
338
+ // ── 9. normative coverage ───────────────────────────────────────────────
339
+ // Every section of a normative document, with the digest a reviewer cites,
340
+ // and whether a requirement or an attestation accounts for it. Linking a
341
+ // specification to a component routes it; only this says it was reviewed.
342
+ const unverifiedRequirements = normative.requirements.filter((r) =>
343
+ !graph.assertions.some((b) => b.subject === r.subject && b.key === r.key && !isNormative(b) && b.confidence !== "candidate"));
344
+
306
345
  const rows = [...classes.entries()].map(([fact, v]) => ({ fact, classification: v.cls, evidence: v.why.slice(0, 4) }));
307
346
  rows.sort((a, b) => (a.fact < b.fact ? -1 : 1));
308
347
  const counts = rows.reduce((acc, r) => { acc[r.classification] = (acc[r.classification] || 0) + 1; return acc; }, {});
@@ -333,7 +372,17 @@ export function audit(repo, cfg, res) {
333
372
  .filter((c) => c.status !== "unresolved")
334
373
  .map((c) => ({ kind: c.kind, subject: c.subject, key: c.key, status: c.status, basis: c.election.basis, ruleId: c.election.ruleId })),
335
374
  documentationContradictions: docContradictions,
375
+ normative: {
376
+ sections: normative.sections,
377
+ uncovered: normative.uncovered,
378
+ requirements: normative.requirements.map((r) => ({
379
+ subject: r.subject, id: r.id, key: r.key, value: r.value,
380
+ source: r.source.path + "#" + r.source.section, satisfiedBy: r.satisfiedBy,
381
+ })),
382
+ },
336
383
  reviewedAmbiguity: [
384
+ ...normative.uncovered.map((s) => ({ kind: "NORMATIVE_UNCOVERED", subject: s.path + "#" + s.section })),
385
+ ...unverifiedRequirements.map((r) => ({ kind: "REQUIREMENT_UNVERIFIED", subject: r.subject + " " + r.key })),
337
386
  ...unresolvedEvents.map((pattern) => ({ kind: "EVENT_WITHOUT_PRODUCER", subject: pattern })),
338
387
  ...ambiguousOwnership.map((ref) => ({ kind: "COMPONENT_OWNERSHIP", subject: ref })),
339
388
  ...unclaimedContracts.map((p) => ({ kind: "UNCLAIMED_CONTRACT", subject: p })),
@@ -10,6 +10,7 @@
10
10
  // fail-closed default: silence is not a resolution.
11
11
  import { AgentDocError, CODES } from "../codes.mjs";
12
12
  import { EVIDENCE_CLASSES, CONFIDENCE } from "./classes.mjs";
13
+ import { isNormative } from "../normative.mjs";
13
14
 
14
15
  export class AuthorityEngine {
15
16
  constructor(rules) {
@@ -75,6 +76,18 @@ export function resolveAssertions(assertions, authority) {
75
76
  const [subject, key] = gk.split("\u0000");
76
77
  const distinct = distinctValues(list);
77
78
 
79
+ // A normative requirement is a claim about what the implementation must
80
+ // do, so it can only be corroborated, never elected on its own word. With
81
+ // no implementation evidence beside it, it stays unresolved: the gap the
82
+ // migration must surface.
83
+ const normative = list.some(isNormative);
84
+ if (normative && list.every((a) => isNormative(a) || a.confidence === "candidate")) {
85
+ // A heuristic candidate is not evidence either way: it stays a candidate,
86
+ // and the requirement stays unverified.
87
+ for (const a of list) a.status = isNormative(a) ? "unresolved" : "candidate";
88
+ continue;
89
+ }
90
+
78
91
  if (distinct.length === 1) {
79
92
  // Agreement across evidence classes is corroboration, not a conflict —
80
93
  // but a lone heuristic candidate is not agreement with anything. It stays
@@ -121,9 +134,19 @@ export function resolveAssertions(assertions, authority) {
121
134
  };
122
135
  let status = "unresolved";
123
136
 
137
+ // 0. A normative requirement and the implementation disagree. No rule may
138
+ // settle that: electing the implementation waives the specification,
139
+ // electing the requirement hides the missing implementation. Only a
140
+ // change to one of them resolves it.
141
+ if (normative) {
142
+ election.rationale =
143
+ "a normative requirement disagrees with the implementation evidence; no authority rule may settle it — " +
144
+ "change the implementation, or change the specification and re-review its section";
145
+ }
146
+
124
147
  // 1. reviewed override wins outright, but the divergence is still recorded.
125
148
  const reviewed = candidates.filter((a) => a.evidenceClass === "REVIEWED_OVERRIDE");
126
- if (rule && rule.elect.length === 1 && rule.elect[0] === "REVIEWED_OVERRIDE" && reviewed.length === 1) {
149
+ if (!normative && rule && rule.elect.length === 1 && rule.elect[0] === "REVIEWED_OVERRIDE" && reviewed.length === 1) {
127
150
  election.elected = reviewed[0];
128
151
  election.basis = "reviewed-override";
129
152
  election.rationale = rule.rationale;
@@ -131,7 +154,7 @@ export function resolveAssertions(assertions, authority) {
131
154
  }
132
155
 
133
156
  // 2. authority rule with a single elected class present among candidates.
134
- if (!election.elected && rule) {
157
+ if (!normative && !election.elected && rule) {
135
158
  const allowed = rule.elect.filter((c) => candidates.some((a) => a.evidenceClass === c));
136
159
  if (allowed.length === 1) {
137
160
  const winners = candidates.filter((a) => a.evidenceClass === allowed[0]);
@@ -149,7 +172,7 @@ export function resolveAssertions(assertions, authority) {
149
172
  }
150
173
 
151
174
  // 3. superset: one candidate's value contains every other candidate's value.
152
- if (!election.elected && candidates.length > 1) {
175
+ if (!normative && !election.elected && candidates.length > 1) {
153
176
  const sup = candidates.find((c) => candidates.every((o) => o === c || containsValue(c.value, o.value)));
154
177
  if (sup) {
155
178
  election.elected = sup;
@@ -160,7 +183,7 @@ export function resolveAssertions(assertions, authority) {
160
183
  }
161
184
 
162
185
  // 4. recency: only when the rule names recency and the winner is a runtime fact.
163
- if (!election.elected && rule && rule.elect.includes("OBSERVED_RUNTIME")) {
186
+ if (!normative && !election.elected && rule && rule.elect.includes("OBSERVED_RUNTIME")) {
164
187
  const runtime = candidates.filter((a) => a.evidenceClass === "OBSERVED_RUNTIME");
165
188
  const dated = runtime.filter((a) => a.observed && a.observed.at);
166
189
  if (dated.length > 1) {
package/core/codes.mjs CHANGED
@@ -58,6 +58,11 @@ export const CODES = {
58
58
  ELECTION_STALE: "AGENTDOC_ELECTION_STALE",
59
59
  UNRESOLVED_FACT: "AGENTDOC_UNRESOLVED_FACT",
60
60
 
61
+ // normative requirements
62
+ NORMATIVE_UNCOVERED: "AGENTDOC_NORMATIVE_UNCOVERED",
63
+ NORMATIVE_SOURCE_STALE: "AGENTDOC_NORMATIVE_SOURCE_STALE",
64
+ REQUIREMENT_UNVERIFIED: "AGENTDOC_REQUIREMENT_UNVERIFIED",
65
+
61
66
  // provenance & determinism
62
67
  PROVENANCE: "AGENTDOC_PROVENANCE",
63
68
  NONDETERMINISTIC: "AGENTDOC_NONDETERMINISTIC",
@@ -87,6 +92,8 @@ export const ACCEPTABLE_WARNING_CODES = Object.freeze([
87
92
  "AGENTDOC_EXTERNAL_DEPENDENCY_UNCLASSIFIED",
88
93
  "AGENTDOC_DOC_ORPHANED",
89
94
  "AGENTDOC_VERIFICATION_UNTIERED",
95
+ "AGENTDOC_NORMATIVE_UNCOVERED",
96
+ "AGENTDOC_REQUIREMENT_UNVERIFIED",
90
97
  ]);
91
98
 
92
99
  export class AgentDocError extends Error {