agent-doc-system 0.0.0-stage → 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/INSTALL_FOR_AGENTS.md +60 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/adapters/ansible.mjs +306 -0
- package/adapters/cache.mjs +27 -0
- package/adapters/contract-consumers.mjs +56 -0
- package/adapters/database.mjs +116 -0
- package/adapters/deploy-config.mjs +34 -0
- package/adapters/dockerfile.mjs +26 -0
- package/adapters/events.mjs +145 -0
- package/adapters/generic.mjs +86 -0
- package/adapters/github-actions.mjs +106 -0
- package/adapters/go.mjs +91 -0
- package/adapters/graphql.mjs +28 -0
- package/adapters/index.mjs +56 -0
- package/adapters/node.mjs +118 -0
- package/adapters/object-store.mjs +64 -0
- package/adapters/oidc.mjs +56 -0
- package/adapters/openapi.mjs +50 -0
- package/adapters/pnpm.mjs +42 -0
- package/adapters/protobuf.mjs +35 -0
- package/adapters/registry.mjs +27 -0
- package/adapters/resource-client.mjs +61 -0
- package/adapters/turborepo.mjs +135 -0
- package/adapters/typescript.mjs +85 -0
- package/adapters/wrangler.mjs +206 -0
- package/bin/agentdoc.mjs +494 -0
- package/bin/commands.mjs +169 -0
- package/bin/usage.txt +28 -0
- package/core/acceptances.mjs +69 -0
- package/core/audit.mjs +456 -0
- package/core/authority/classes.mjs +39 -0
- package/core/authority/engine.mjs +266 -0
- package/core/authority/facts.mjs +41 -0
- package/core/codes.mjs +126 -0
- package/core/compile.mjs +1018 -0
- package/core/contracts.mjs +121 -0
- package/core/derive.mjs +102 -0
- package/core/descriptors.mjs +489 -0
- package/core/determinism.mjs +95 -0
- package/core/discover.mjs +147 -0
- package/core/facts.mjs +53 -0
- package/core/fsx.mjs +348 -0
- package/core/graph.mjs +260 -0
- package/core/impact.mjs +175 -0
- package/core/indexes.mjs +165 -0
- package/core/jsonschema.mjs +298 -0
- package/core/normative.mjs +171 -0
- package/core/observations.mjs +129 -0
- package/core/provenance.mjs +83 -0
- package/core/query.mjs +384 -0
- package/core/relations.mjs +286 -0
- package/core/scaffold.mjs +322 -0
- package/core/secrets.mjs +92 -0
- package/core/sourcescan.mjs +88 -0
- package/core/toml.mjs +163 -0
- package/core/yaml.mjs +477 -0
- package/docs/ADAPTERS.md +207 -0
- package/docs/AUTHORITY.md +269 -0
- package/docs/CI.md +138 -0
- package/docs/COMPARISON-KODA.md +164 -0
- package/docs/EVALS.md +166 -0
- package/docs/MIGRATION.md +261 -0
- package/docs/PROVENANCE.md +133 -0
- package/docs/QUERY.md +180 -0
- package/docs/SPEC.md +339 -0
- package/docs/VERIFICATION.md +28 -0
- 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/generic-ci.sh +28 -0
- package/docs/ci/github-actions.yml +35 -0
- package/evals/harness.mjs +426 -0
- package/evals/scenarios/fixture-a-01-change-api.json +9 -0
- package/evals/scenarios/fixture-a-02-change-db.json +9 -0
- package/evals/scenarios/fixture-a-03-enforce-constraint.json +9 -0
- package/evals/scenarios/fixture-b-01-change-proto.json +9 -0
- package/evals/scenarios/fixture-b-02-change-write-path.json +9 -0
- package/evals/scenarios/fixture-b-03-change-shared-lib.json +9 -0
- package/evals/scenarios/fixture-b-04-journey.json +9 -0
- package/evals/scenarios/fixture-c-01-mixed-runtime-edge.json +19 -0
- package/evals/scenarios/fixture-c-02-stale-doc-contradiction.json +9 -0
- package/evals/scenarios/fixture-c-03-shared-vocabulary.json +9 -0
- package/evals/scenarios/fixture-c-04-contract-change.json +9 -0
- package/evals/scenarios/koda-01-modify-api-consumer.json +26 -0
- package/evals/scenarios/koda-02-change-db-schema.json +20 -0
- package/evals/scenarios/koda-03-change-auth.json +21 -0
- package/evals/scenarios/koda-04-add-event-producer.json +19 -0
- package/evals/scenarios/koda-05-modify-worker-binding.json +25 -0
- package/evals/scenarios/koda-06-change-cross-component-endpoint.json +22 -0
- package/evals/scenarios/koda-07-change-shared-package.json +16 -0
- package/evals/scenarios/koda-08-change-runtime-cron.json +23 -0
- package/evals/scenarios/koda-09-debug-production-mismatch.json +26 -0
- package/evals/thresholds.json +11 -0
- package/package.json +46 -4
- package/schemas/api.schema.json +84 -0
- package/schemas/common.schema.json +147 -0
- package/schemas/component.schema.json +44 -0
- package/schemas/config.schema.json +801 -0
- package/schemas/domain.schema.json +14 -0
- package/schemas/graph.schema.json +1084 -0
- package/schemas/journeys.schema.json +47 -0
- package/schemas/observation.schema.json +143 -0
- package/schemas/resource.schema.json +28 -0
- package/schemas/system.schema.json +26 -0
- package/skills/agent-doc-system/SKILL.md +117 -0
- package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
- package/skills/agent-doc-system/references/cli.md +97 -0
- package/skills/agent-doc-system/references/create-migrate.md +113 -0
- package/skills/agent-doc-system/references/maintenance.md +52 -0
- package/templates/ADR.md +26 -0
- package/templates/ARCHITECTURE.md +19 -0
- package/templates/CONSTRAINTS.md +17 -0
- package/templates/PRODUCT.md +17 -0
- package/templates/api.yaml +10 -0
- package/templates/ci.yml +31 -0
- package/templates/component.yaml +14 -0
- package/templates/domain.yaml +6 -0
- package/templates/journey.yaml +7 -0
- package/templates/observation.yaml +19 -0
- package/templates/resource.yaml +8 -0
- package/templates/system.yaml +10 -0
- package/templates/warning-acceptance.yaml +14 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Zero-dependency JSON Schema validator (2020-12 subset).
|
|
2
|
+
//
|
|
3
|
+
// Supported keywords — exactly what the agentdoc schemas use:
|
|
4
|
+
// $ref (local bundle ids and #/$defs/... pointers), $defs, type, const, enum,
|
|
5
|
+
// required, properties, additionalProperties, patternProperties, items,
|
|
6
|
+
// prefixItems, minItems, maxItems, uniqueItems, minLength, maxLength,
|
|
7
|
+
// pattern, minimum, maximum, allOf, anyOf, oneOf, not, if/then/else,
|
|
8
|
+
// propertyNames, minProperties, format (annotation only).
|
|
9
|
+
//
|
|
10
|
+
// Anything else in a schema is a hard authoring error, so a schema can never
|
|
11
|
+
// silently validate less than it appears to. See SPEC.md "Schema dialect".
|
|
12
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
13
|
+
|
|
14
|
+
export const SUPPORTED_KEYWORDS = Object.freeze(new Set([
|
|
15
|
+
"$schema", "$id", "$defs", "$ref", "title", "description", "examples", "default", "deprecated",
|
|
16
|
+
"type", "const", "enum", "required", "properties", "additionalProperties", "patternProperties",
|
|
17
|
+
"items", "prefixItems", "minItems", "maxItems", "uniqueItems", "minLength", "maxLength",
|
|
18
|
+
"pattern", "minimum", "maximum", "allOf", "anyOf", "oneOf", "not", "if", "then", "else",
|
|
19
|
+
"propertyNames", "minProperties", "$comment",
|
|
20
|
+
]));
|
|
21
|
+
|
|
22
|
+
function typeOf(v) {
|
|
23
|
+
if (v === null) return "null";
|
|
24
|
+
if (Array.isArray(v)) return "array";
|
|
25
|
+
if (Number.isInteger(v)) return "integer";
|
|
26
|
+
return typeof v;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function typeMatches(v, t) {
|
|
30
|
+
const a = typeOf(v);
|
|
31
|
+
if (t === "number") return a === "number" || a === "integer";
|
|
32
|
+
if (t === "integer") return a === "integer";
|
|
33
|
+
return a === t;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deepEqual(a, b) {
|
|
37
|
+
if (a === b) return true;
|
|
38
|
+
if (typeOf(a) !== typeOf(b)) {
|
|
39
|
+
if (!(typeof a === "number" && typeof b === "number")) return false;
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(a)) {
|
|
42
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
43
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
44
|
+
}
|
|
45
|
+
if (a && typeof a === "object") {
|
|
46
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return false;
|
|
47
|
+
const ka = Object.keys(a).sort();
|
|
48
|
+
const kb = Object.keys(b).sort();
|
|
49
|
+
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
|
|
50
|
+
return ka.every((k) => deepEqual(a[k], b[k]));
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SchemaBundle {
|
|
56
|
+
constructor(schemas) {
|
|
57
|
+
this.byId = new Map();
|
|
58
|
+
for (const s of schemas) this.byId.set(s.$id, s);
|
|
59
|
+
}
|
|
60
|
+
static fromMap(map) {
|
|
61
|
+
return new SchemaBundle([...map.values()]);
|
|
62
|
+
}
|
|
63
|
+
get(id) {
|
|
64
|
+
const s = this.byId.get(id);
|
|
65
|
+
if (!s) throw new AgentDocError(CODES.SCHEMA, "schema not found in bundle: " + id);
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
// Resolve a cross-schema $ref. Schemas may refer to a sibling by bare file
|
|
69
|
+
// name ("common.json#/$defs/x"); both that alias and the fully qualified id
|
|
70
|
+
// are accepted, so a bundle can be registered under either.
|
|
71
|
+
//
|
|
72
|
+
// Returns the target *and* the document it was found in. The document matters:
|
|
73
|
+
// a node reached through `common.json#/$defs/metadata` contains its own local
|
|
74
|
+
// `#/$defs/name` pointers, which must resolve against common rather than
|
|
75
|
+
// against whichever schema happened to lead here.
|
|
76
|
+
resolve(baseId, ref) {
|
|
77
|
+
const [file, pointer] = splitRef(ref);
|
|
78
|
+
const candidates = [];
|
|
79
|
+
if (file) {
|
|
80
|
+
if (this.byId.has(file)) candidates.push(file);
|
|
81
|
+
if (baseId) candidates.push(relativeTo(baseId, file));
|
|
82
|
+
for (const id of this.byId.keys()) {
|
|
83
|
+
if (id === file || id.endsWith("/" + file)) candidates.push(id);
|
|
84
|
+
}
|
|
85
|
+
} else if (baseId) {
|
|
86
|
+
candidates.push(baseId);
|
|
87
|
+
}
|
|
88
|
+
for (const c of candidates) {
|
|
89
|
+
if (!this.byId.has(c)) continue;
|
|
90
|
+
let target = this.byId.get(c);
|
|
91
|
+
if (pointer) {
|
|
92
|
+
for (const part of pointer.split("/").filter(Boolean)) {
|
|
93
|
+
target = target[unescapePointer(part)];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (target !== undefined) return { node: target, document: this.byId.get(c) };
|
|
97
|
+
}
|
|
98
|
+
throw new AgentDocError(CODES.SCHEMA, "cannot resolve $ref " + ref + " from " + baseId);
|
|
99
|
+
}
|
|
100
|
+
validator(schemaOrId) {
|
|
101
|
+
const root = typeof schemaOrId === "string" ? this.get(schemaOrId) : schemaOrId;
|
|
102
|
+
return new Validator(this, root);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class Validator {
|
|
107
|
+
constructor(bundle, root) {
|
|
108
|
+
this.bundle = bundle;
|
|
109
|
+
this.root = root;
|
|
110
|
+
}
|
|
111
|
+
// Returns null when valid, else an array of {instancePath, keyword, message}.
|
|
112
|
+
validate(value) {
|
|
113
|
+
const errs = [];
|
|
114
|
+
this.#check(this.root, value, "", errs, this.root);
|
|
115
|
+
return errs.length ? errs : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A `#/...` pointer resolves against the *document* that contains it, and that
|
|
119
|
+
// document changes as a cross-file `$ref` is followed. Two bugs live here and
|
|
120
|
+
// both failed silently, because dereferencing to `undefined` means "no
|
|
121
|
+
// constraint" rather than an error:
|
|
122
|
+
//
|
|
123
|
+
// 1. Resolving against the referring subschema rather than its document looks
|
|
124
|
+
// up `#/$defs/E` inside `{"$ref": "#/$defs/E"}` and yields undefined, so any
|
|
125
|
+
// schema using `$ref` for an enum inside `items` validated anything at all.
|
|
126
|
+
// 2. Resolving against `this.root` always is wrong in the other direction:
|
|
127
|
+
// after following `common.json#/$defs/metadata`, the local `#/$defs/name`
|
|
128
|
+
// inside *common* must resolve against common, not against the original
|
|
129
|
+
// root. That made every component descriptor fail to validate.
|
|
130
|
+
//
|
|
131
|
+
// `doc` therefore tracks the current base document, and `#` alone means the
|
|
132
|
+
// current document's root.
|
|
133
|
+
// Resolve `schema` to a node, and report which document that node's own local
|
|
134
|
+
// `#/...` pointers must be resolved against. Threading the document explicitly
|
|
135
|
+
// is what makes nested cross-file references work: without it, a
|
|
136
|
+
// `#/$defs/repoPath` written inside `common.json` was looked up in whichever
|
|
137
|
+
// schema happened to lead there, and dereferencing to `undefined` means "no
|
|
138
|
+
// constraint" rather than an error — a silent soundness hole.
|
|
139
|
+
#resolve(schema, doc = this.root, seen = new Set()) {
|
|
140
|
+
let s = schema;
|
|
141
|
+
let base = doc;
|
|
142
|
+
while (s && typeof s.$ref === "string") {
|
|
143
|
+
const id = s.$ref;
|
|
144
|
+
const tag = (base && base.$id) + "|" + id;
|
|
145
|
+
if (seen.has(tag)) throw new AgentDocError(CODES.SCHEMA, "cyclic $ref: " + id);
|
|
146
|
+
seen.add(tag);
|
|
147
|
+
if (id.startsWith("#")) {
|
|
148
|
+
if (id === "#") { s = base; continue; }
|
|
149
|
+
if (!id.startsWith("#/")) throw new AgentDocError(CODES.SCHEMA, "unsupported $ref " + id);
|
|
150
|
+
s = pointerGet(base, id.slice(2).split("/").map(unescapePointer));
|
|
151
|
+
if (s === undefined) {
|
|
152
|
+
throw new AgentDocError(CODES.SCHEMA, "cannot resolve local $ref " + id + " against " + base.$id);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const found = this.bundle.resolve(base.$id, id);
|
|
156
|
+
s = found.node;
|
|
157
|
+
base = found.document;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { node: s, document: base };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#check(rawSchema, value, ip, errs, doc = this.root) {
|
|
164
|
+
const resolved = this.#resolve(rawSchema, doc);
|
|
165
|
+
const schema = resolved.node;
|
|
166
|
+
const base = resolved.document;
|
|
167
|
+
if (schema === true || schema === undefined) return;
|
|
168
|
+
if (schema === false) {
|
|
169
|
+
errs.push({ instancePath: ip || "/", keyword: "false", message: "schema forbids any value" });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const add = (keyword, message) => errs.push({ instancePath: ip || "/", keyword, message });
|
|
173
|
+
|
|
174
|
+
if (schema.type !== undefined) {
|
|
175
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
176
|
+
if (!types.some((t) => typeMatches(value, t))) {
|
|
177
|
+
add("type", "must be " + types.join("|") + " (got " + typeOf(value) + ")");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (schema.const !== undefined && !deepEqual(value, schema.const)) {
|
|
182
|
+
add("const", "must equal " + JSON.stringify(schema.const));
|
|
183
|
+
}
|
|
184
|
+
if (schema.enum !== undefined && !schema.enum.some((e) => deepEqual(value, e))) {
|
|
185
|
+
add("enum", "must be one of " + JSON.stringify(schema.enum));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const t = typeOf(value);
|
|
189
|
+
if (t === "string") {
|
|
190
|
+
if (schema.minLength !== undefined && [...value].length < schema.minLength) add("minLength", "shorter than " + schema.minLength);
|
|
191
|
+
if (schema.maxLength !== undefined && [...value].length > schema.maxLength) add("maxLength", "longer than " + schema.maxLength);
|
|
192
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern, "u").test(value)) {
|
|
193
|
+
add("pattern", "does not match " + schema.pattern);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (t === "number" || t === "integer") {
|
|
197
|
+
if (schema.minimum !== undefined && value < schema.minimum) add("minimum", "below minimum " + schema.minimum);
|
|
198
|
+
if (schema.maximum !== undefined && value > schema.maximum) add("maximum", "above maximum " + schema.maximum);
|
|
199
|
+
}
|
|
200
|
+
if (t === "array") {
|
|
201
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) add("minItems", "fewer than " + schema.minItems + " items");
|
|
202
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) add("maxItems", "more than " + schema.maxItems + " items");
|
|
203
|
+
if (schema.uniqueItems === true) {
|
|
204
|
+
for (let i = 0; i < value.length; i++) {
|
|
205
|
+
for (let j = i + 1; j < value.length; j++) {
|
|
206
|
+
if (deepEqual(value[i], value[j])) add("uniqueItems", "duplicate item at index " + j);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const prefix = schema.prefixItems || [];
|
|
211
|
+
for (let i = 0; i < value.length; i++) {
|
|
212
|
+
if (i < prefix.length) this.#check(prefix[i], value[i], ip + "/" + i, errs, base);
|
|
213
|
+
else if (schema.items !== undefined) this.#check(schema.items, value[i], ip + "/" + i, errs, base);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (t === "object") {
|
|
217
|
+
const keys = Object.keys(value);
|
|
218
|
+
if (schema.minProperties !== undefined && keys.length < schema.minProperties) add("minProperties", "fewer than " + schema.minProperties + " properties");
|
|
219
|
+
for (const r of schema.required || []) {
|
|
220
|
+
if (!Object.prototype.hasOwnProperty.call(value, r)) add("required", "missing required property " + JSON.stringify(r));
|
|
221
|
+
}
|
|
222
|
+
const props = schema.properties || {};
|
|
223
|
+
const patterns = Object.entries(schema.patternProperties || {});
|
|
224
|
+
for (const k of keys) {
|
|
225
|
+
const escaped = k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
226
|
+
const childIp = ip + "/" + escaped;
|
|
227
|
+
let covered = false;
|
|
228
|
+
if (Object.prototype.hasOwnProperty.call(props, k)) {
|
|
229
|
+
covered = true;
|
|
230
|
+
this.#check(props[k], value[k], childIp, errs, base);
|
|
231
|
+
}
|
|
232
|
+
for (const [re, sub] of patterns) {
|
|
233
|
+
if (new RegExp(re, "u").test(k)) {
|
|
234
|
+
covered = true;
|
|
235
|
+
this.#check(sub, value[k], childIp, errs, base);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!covered) {
|
|
239
|
+
if (schema.additionalProperties === false) {
|
|
240
|
+
errs.push({ instancePath: childIp, keyword: "additionalProperties", message: "unknown property " + JSON.stringify(k) });
|
|
241
|
+
} else if (schema.additionalProperties !== undefined) {
|
|
242
|
+
this.#check(schema.additionalProperties, value[k], childIp, errs, base);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (schema.propertyNames !== undefined) this.#check(schema.propertyNames, k, childIp, errs, base);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const sub of schema.allOf || []) this.#check(sub, value, ip, errs, base);
|
|
250
|
+
if (schema.anyOf) {
|
|
251
|
+
const ok = schema.anyOf.some((s) => this.#sub(value, s, base));
|
|
252
|
+
if (!ok) add("anyOf", "does not match any allowed variant");
|
|
253
|
+
}
|
|
254
|
+
if (schema.oneOf) {
|
|
255
|
+
const n = schema.oneOf.filter((s) => this.#sub(value, s, base)).length;
|
|
256
|
+
if (n !== 1) add("oneOf", "must match exactly one variant (matched " + n + ")");
|
|
257
|
+
}
|
|
258
|
+
if (schema.not !== undefined && this.#sub(value, schema.not, base)) add("not", "matches a forbidden shape");
|
|
259
|
+
if (schema.if !== undefined) {
|
|
260
|
+
if (this.#sub(value, schema.if, base)) this.#check(schema.then, value, ip, errs, base);
|
|
261
|
+
else if (schema.else !== undefined) this.#check(schema.else, value, ip, errs, base);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#sub(value, schema, doc = this.root) {
|
|
266
|
+
const errs = [];
|
|
267
|
+
this.#check(schema, value, "", errs, doc);
|
|
268
|
+
return errs.length === 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function unescapePointer(s) {
|
|
273
|
+
return s.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function pointerGet(root, parts) {
|
|
277
|
+
let cur = root;
|
|
278
|
+
for (const p of parts) {
|
|
279
|
+
if (cur === undefined || cur === null) return undefined;
|
|
280
|
+
cur = cur[p];
|
|
281
|
+
}
|
|
282
|
+
return cur;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function relativeTo(baseId, ref) {
|
|
286
|
+
const base = baseId || "";
|
|
287
|
+
const cut = base.lastIndexOf("/");
|
|
288
|
+
return cut >= 0 ? base.slice(0, cut + 1) + ref : ref;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function splitRef(ref) {
|
|
292
|
+
const at = ref.indexOf("#");
|
|
293
|
+
return at < 0 ? [ref, null] : [ref.slice(0, at), ref.slice(at + 1)];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function formatErrors(errs, limit = 8) {
|
|
297
|
+
return errs.slice(0, limit).map((e) => (e.instancePath || "/") + " " + e.message).join("; ");
|
|
298
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Observation ingestion.
|
|
2
|
+
//
|
|
3
|
+
// An ObservationSet is a committed, secret-free record of what an external
|
|
4
|
+
// system actually said at a known time. Ingesting it produces
|
|
5
|
+
// OBSERVED_RUNTIME assertions. It can never overwrite an authored fact: it is
|
|
6
|
+
// added to the same (subject, key) group and the authority engine decides what
|
|
7
|
+
// happens. Freshness is evaluated at gate time (check/query), never at compile
|
|
8
|
+
// time, so a compile stays a pure function of the checkout.
|
|
9
|
+
import { CODES, AgentDocError } from "./codes.mjs";
|
|
10
|
+
|
|
11
|
+
export function ingestObservations(ctx) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const { sets } = ctx.observations;
|
|
14
|
+
const byName = new Map();
|
|
15
|
+
for (const s of sets) {
|
|
16
|
+
if (byName.has(s.meta.name)) {
|
|
17
|
+
throw new AgentDocError(
|
|
18
|
+
CODES.DUPLICATE_IDENTITY,
|
|
19
|
+
"duplicate observation set name '" + s.meta.name + "' (also in " + byName.get(s.meta.name).file + ")",
|
|
20
|
+
{ path: s.file }
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
byName.set(s.meta.name, s);
|
|
24
|
+
}
|
|
25
|
+
for (const s of sets) {
|
|
26
|
+
const m = s.meta;
|
|
27
|
+
if (m.supersedes && !byName.has(m.supersedes)) {
|
|
28
|
+
throw new AgentDocError(
|
|
29
|
+
CODES.REF_UNRESOLVED,
|
|
30
|
+
"observation set '" + m.name + "' supersedes unknown set '" + m.supersedes + "'",
|
|
31
|
+
{ path: s.file }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const bundleProv = ctx.prov.add(
|
|
35
|
+
"runtime",
|
|
36
|
+
m.evidenceBundle,
|
|
37
|
+
"observation:" + m.collector.replace(/\s+/g, "-").toLowerCase(),
|
|
38
|
+
["obs:" + m.name],
|
|
39
|
+
{ jsonPointer: "/facts" }
|
|
40
|
+
);
|
|
41
|
+
const observed = {
|
|
42
|
+
at: m.capturedAt,
|
|
43
|
+
environment: m.environment,
|
|
44
|
+
sourceSystem: m.sourceSystem,
|
|
45
|
+
collector: m.collector,
|
|
46
|
+
evidenceBundle: m.evidenceBundle,
|
|
47
|
+
retrievalMethod: m.collector,
|
|
48
|
+
supersedes: m.supersedes || null,
|
|
49
|
+
};
|
|
50
|
+
const facts = [];
|
|
51
|
+
for (const f of s.facts) {
|
|
52
|
+
const fact = ctx.facts.add({
|
|
53
|
+
subject: f.subject,
|
|
54
|
+
key: f.key,
|
|
55
|
+
value: f.value,
|
|
56
|
+
evidenceClass: "OBSERVED_RUNTIME",
|
|
57
|
+
confidence: "direct",
|
|
58
|
+
provRecs: [bundleProv],
|
|
59
|
+
semantics: f.semantics,
|
|
60
|
+
observed,
|
|
61
|
+
source: s.file,
|
|
62
|
+
});
|
|
63
|
+
facts.push({
|
|
64
|
+
id: f.id,
|
|
65
|
+
subject: f.subject,
|
|
66
|
+
key: f.key,
|
|
67
|
+
value: f.value,
|
|
68
|
+
semantics: f.semantics,
|
|
69
|
+
assertionId: fact.id,
|
|
70
|
+
observedRefs: [...(f.observedRefs || [])].sort(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
out.push({
|
|
74
|
+
id: m.name,
|
|
75
|
+
name: m.name,
|
|
76
|
+
description: m.description,
|
|
77
|
+
environment: m.environment,
|
|
78
|
+
sourceSystem: m.sourceSystem,
|
|
79
|
+
collector: m.collector,
|
|
80
|
+
capturedAt: m.capturedAt,
|
|
81
|
+
maxAgeDays: m.maxAgeDays,
|
|
82
|
+
evidenceBundle: m.evidenceBundle,
|
|
83
|
+
supersedes: m.supersedes || null,
|
|
84
|
+
facts,
|
|
85
|
+
provenanceIds: null,
|
|
86
|
+
provs: new Set([bundleProv]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
out.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Freshness of an observation is a time-dependent judgement and therefore lives
|
|
94
|
+
// outside the compiled graph. `now` is injected so tests and CI are hermetic.
|
|
95
|
+
export function observationFreshness(observations, now = new Date()) {
|
|
96
|
+
const out = [];
|
|
97
|
+
// A superseded set is history, not a promise of freshness — its maxAgeDays
|
|
98
|
+
// no longer gates, or every superseding observation would immediately make
|
|
99
|
+
// the one it replaced a permanent staleness failure.
|
|
100
|
+
const supersededIds = new Set((observations || []).map((o) => o.supersedes).filter(Boolean));
|
|
101
|
+
for (const o of observations || []) {
|
|
102
|
+
if (supersededIds.has(o.id)) continue;
|
|
103
|
+
const captured = Date.parse(o.capturedAt);
|
|
104
|
+
const ageDays = Math.floor((now.getTime() - captured) / 86400000);
|
|
105
|
+
out.push({
|
|
106
|
+
id: o.id,
|
|
107
|
+
environment: o.environment,
|
|
108
|
+
capturedAt: o.capturedAt,
|
|
109
|
+
ageDays,
|
|
110
|
+
maxAgeDays: o.maxAgeDays,
|
|
111
|
+
stale: !Number.isFinite(ageDays) || ageDays > o.maxAgeDays,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function stalenessDiagnostics(freshness) {
|
|
118
|
+
return freshness
|
|
119
|
+
.filter((f) => f.stale)
|
|
120
|
+
.map((f) => ({
|
|
121
|
+
severity: "error",
|
|
122
|
+
code: CODES.OBSERVATION_STALE,
|
|
123
|
+
subject: f.id,
|
|
124
|
+
message:
|
|
125
|
+
"observation set '" + f.id + "' (" + f.environment + ") was captured " + f.capturedAt +
|
|
126
|
+
" and exceeds its declared maxAgeDays of " + f.maxAgeDays + " — re-observe the source system or restate the fact",
|
|
127
|
+
paths: [],
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Provenance registry.
|
|
2
|
+
//
|
|
3
|
+
// Every fact that reaches the compiled graph is backed by a record carrying the
|
|
4
|
+
// evidence path, extractor identity, content hash, and RFC 6901 subject
|
|
5
|
+
// pointers into the graph. Provenance classes:
|
|
6
|
+
// declared — the file states the fact outright (authored descriptor, config)
|
|
7
|
+
// observed — the file shows the fact (manifest, source file, config)
|
|
8
|
+
// validated — two independent files agree (a generated client + the contract)
|
|
9
|
+
// runtime — read from an external system via a recorded collector
|
|
10
|
+
// standard — the canonical authority is a published standard
|
|
11
|
+
import { sha256Hex } from "./fsx.mjs";
|
|
12
|
+
|
|
13
|
+
// Extractor identities appear in the graph and are matched by tooling, so they
|
|
14
|
+
// are normalised to a slug. "discovery:pnpm" and "pnpm" must not be two
|
|
15
|
+
// different extractors.
|
|
16
|
+
export function slugExtractor(name) {
|
|
17
|
+
const s = String(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
18
|
+
return s || "unknown";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// `reviewed` is the provenance class for a human interpretation recorded in
|
|
22
|
+
// configuration. It was previously filed as `declared`, which mapped to
|
|
23
|
+
// AUTHORED — the strongest class in the model — so a reviewed override merged
|
|
24
|
+
// with a derived edge and was relabelled AUTHORED, silently promoting a human
|
|
25
|
+
// judgement to the top of the hierarchy. The authority model elects classes per
|
|
26
|
+
// fact; a provenance class must not decide that on its own.
|
|
27
|
+
export const CLASSES = Object.freeze(["declared", "observed", "validated", "runtime", "standard", "reviewed"]);
|
|
28
|
+
|
|
29
|
+
export class ProvRegistry {
|
|
30
|
+
constructor(repo) {
|
|
31
|
+
this.repo = repo;
|
|
32
|
+
this.records = [];
|
|
33
|
+
}
|
|
34
|
+
// subjects are symbolic keys ("rel:pending", "ent:<ref>", "evt:<pattern>")
|
|
35
|
+
// resolved to graph pointers after assembly. Callers add "pending" first and
|
|
36
|
+
// rebind later; see bindPending in graph.mjs.
|
|
37
|
+
add(cls, path, extractorName, subjects, location, extractorVersion = "1.0.0") {
|
|
38
|
+
if (!CLASSES.includes(cls)) throw new Error("unknown provenance class " + cls);
|
|
39
|
+
const name = slugExtractor(extractorName);
|
|
40
|
+
const buf = this.repo.readBytes(path);
|
|
41
|
+
const rec = {
|
|
42
|
+
cls,
|
|
43
|
+
path,
|
|
44
|
+
extractor: { name, version: extractorVersion },
|
|
45
|
+
subjects: new Set(subjects.filter(Boolean)),
|
|
46
|
+
hash: "sha256:" + sha256Hex(buf),
|
|
47
|
+
location,
|
|
48
|
+
};
|
|
49
|
+
this.records.push(rec);
|
|
50
|
+
return rec;
|
|
51
|
+
}
|
|
52
|
+
// Canonical order: path, extractor, first subject. IDs are positional, so the
|
|
53
|
+
// numbering is a pure function of the input set.
|
|
54
|
+
finalize(resolve) {
|
|
55
|
+
const sorted = [...this.records].sort((a, b) => {
|
|
56
|
+
const ka = a.path + "|" + a.extractor.name + "|" + [...a.subjects].sort()[0];
|
|
57
|
+
const kb = b.path + "|" + b.extractor.name + "|" + [...b.subjects].sort()[0];
|
|
58
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
59
|
+
});
|
|
60
|
+
const list = sorted.map((r, i) => {
|
|
61
|
+
const subjects = [...r.subjects].map(resolve).sort();
|
|
62
|
+
const out = {
|
|
63
|
+
id: "p-" + String(i + 1).padStart(4, "0"),
|
|
64
|
+
class: r.cls,
|
|
65
|
+
path: r.path,
|
|
66
|
+
extractor: { name: r.extractor.name, version: r.extractor.version },
|
|
67
|
+
contentHash: r.hash,
|
|
68
|
+
subjects,
|
|
69
|
+
};
|
|
70
|
+
if (r.location) out.location = r.location;
|
|
71
|
+
return out;
|
|
72
|
+
});
|
|
73
|
+
const idOf = new Map(sorted.map((r, i) => [r, "p-" + String(i + 1).padStart(4, "0")]));
|
|
74
|
+
return { list, idOf };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Collect the distinct extractor identities for the compiler stamp.
|
|
79
|
+
export function extractorSet(prov) {
|
|
80
|
+
const m = new Map();
|
|
81
|
+
for (const r of prov.records) m.set(r.extractor.name, r.extractor.version);
|
|
82
|
+
return [...m.entries()].sort().map(([name, version]) => ({ name, version }));
|
|
83
|
+
}
|