agent-doc-system 0.0.0-stage → 1.0.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 +151 -2
- 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 +55 -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 +478 -0
- package/bin/usage.txt +26 -0
- package/core/acceptances.mjs +69 -0
- package/core/audit.mjs +407 -0
- package/core/authority/classes.mjs +39 -0
- package/core/authority/engine.mjs +243 -0
- package/core/authority/facts.mjs +41 -0
- package/core/codes.mjs +119 -0
- package/core/compile.mjs +881 -0
- package/core/contracts.mjs +121 -0
- package/core/derive.mjs +102 -0
- package/core/descriptors.mjs +486 -0
- package/core/determinism.mjs +95 -0
- package/core/discover.mjs +147 -0
- package/core/facts.mjs +53 -0
- package/core/fsx.mjs +342 -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/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 +475 -0
- package/docs/ADAPTERS.md +172 -0
- package/docs/AUTHORITY.md +260 -0
- package/docs/CI.md +136 -0
- package/docs/COMPARISON-KODA.md +164 -0
- package/docs/EVALS.md +166 -0
- package/docs/MIGRATION.md +193 -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/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 +107 -0
- package/schemas/component.schema.json +40 -0
- package/schemas/config.schema.json +685 -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 +113 -0
- package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
- package/skills/agent-doc-system/references/cli.md +89 -0
- package/skills/agent-doc-system/references/create-migrate.md +101 -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,42 @@
|
|
|
1
|
+
// pnpm workspace adapter: contributes workspace member roots so the descriptor
|
|
2
|
+
// globs do not have to know the layout, and records the workspace file as
|
|
3
|
+
// evidence for the repository as a whole.
|
|
4
|
+
import { Adapter } from "./registry.mjs";
|
|
5
|
+
import { parseYamlDocuments } from "../core/yaml.mjs";
|
|
6
|
+
|
|
7
|
+
export class PnpmAdapter extends Adapter {
|
|
8
|
+
static adapterName = "pnpm";
|
|
9
|
+
constructor() {
|
|
10
|
+
super({ name: "pnpm", version: "1.0.0", kind: "workspace" });
|
|
11
|
+
}
|
|
12
|
+
roots(ctx) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const ws = "pnpm-workspace.yaml";
|
|
15
|
+
if (!ctx.repo.exists(ws)) return out;
|
|
16
|
+
let docs;
|
|
17
|
+
try {
|
|
18
|
+
docs = parseYamlDocuments(ctx.repo.readText(ws), ws);
|
|
19
|
+
} catch {
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
const globs = (docs[0] && docs[0].doc && docs[0].doc.packages) || [];
|
|
23
|
+
ctx.repo.readText(ws);
|
|
24
|
+
for (const g of Array.isArray(globs) ? globs : []) {
|
|
25
|
+
if (typeof g !== "string" || g.includes("..")) continue;
|
|
26
|
+
const segs = g.split("/").filter((s) => s.length && s !== "**");
|
|
27
|
+
const starAt = segs.findIndex((s) => s === "*");
|
|
28
|
+
if (starAt < 0) continue;
|
|
29
|
+
const base = segs.slice(0, starAt).join("/");
|
|
30
|
+
if (!base || !ctx.repo.isDir(base)) continue;
|
|
31
|
+
// One level of expansion is enough to enumerate members; deeper layouts
|
|
32
|
+
// are picked up by the descriptor globs.
|
|
33
|
+
for (const entry of ctx.repo.listDir(base)) {
|
|
34
|
+
if (!entry.endsWith("/")) continue;
|
|
35
|
+
const dir = base + "/" + entry.replace(/\/$/, "");
|
|
36
|
+
if (!ctx.repo.isDir(dir)) continue;
|
|
37
|
+
if (ctx.repo.exists(dir + "/package.json")) out.push({ root: dir, importable: true, deployable: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Protobuf adapter: .proto files as canonical contracts, with the service and
|
|
2
|
+
// method inventory an agent needs and deterministic consumer resolution.
|
|
3
|
+
import { Adapter } from "./registry.mjs";
|
|
4
|
+
import { resolveContractConsumers, isBuildConfig } from "./contract-consumers.mjs";
|
|
5
|
+
|
|
6
|
+
export class ProtobufAdapter extends Adapter {
|
|
7
|
+
static adapterName = "protobuf";
|
|
8
|
+
constructor() {
|
|
9
|
+
super({ name: "protobuf", version: "1.0.0", kind: "contract" });
|
|
10
|
+
}
|
|
11
|
+
contracts(ctx) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const api of ctx.sources.entities.filter((e) => e.kind === "API" && e.doc.spec.type === "protobuf")) {
|
|
14
|
+
const ref = api.doc.spec.contract.ref;
|
|
15
|
+
const text = ctx.repo.readText(ref);
|
|
16
|
+
const methods = [];
|
|
17
|
+
const packages = [];
|
|
18
|
+
for (const m of text.matchAll(/^\s*package\s+([A-Za-z0-9_.]+)\s*;/gm)) packages.push(m[1]);
|
|
19
|
+
for (const m of text.matchAll(/service\s+([A-Za-z0-9_]+)\s*\{([\s\S]*?)\n\}/g)) {
|
|
20
|
+
for (const r of m[2].matchAll(/rpc\s+([A-Za-z0-9_]+)\s*\(\s*(stream\s+)?([A-Za-z0-9_.]+)/g)) {
|
|
21
|
+
methods.push({ service: m[1], method: r[1], request: r[3], streaming: Boolean(r[2]) });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
methods.sort((a, b) => ((a.service + a.method) < (b.service + b.method) ? -1 : 1));
|
|
25
|
+
ctx.addContract({ apiRef: api.ref, ref, services: [...new Set(text.match(/service\s+([A-Za-z0-9_]+)/g) || [])].length, methods, packages: [...new Set(packages)].sort() });
|
|
26
|
+
|
|
27
|
+
const consumers = resolveContractConsumers(ctx, ref, api.doc.spec.provider);
|
|
28
|
+
for (const [consumerRef, files] of consumers) {
|
|
29
|
+
const provs = files.map((f) => ctx.prov.add(isBuildConfig(f) ? "validated" : "observed", f, "api-contract-extractor", ["rel:pending"]));
|
|
30
|
+
ctx.addEdge("consumesApi", consumerRef, api.ref, { via: files.some(isBuildConfig) ? "codegen" : "source-reference" }, provs, "DERIVED");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Adapter contract.
|
|
2
|
+
//
|
|
3
|
+
// An adapter knows one technology's vocabulary: how to recognise a unit root,
|
|
4
|
+
// how to read its manifest, and which deterministic facts it can extract. It
|
|
5
|
+
// must NOT know about the repository's folder layout, its domain names, its
|
|
6
|
+
// event names or its providers. Everything project-specific arrives through
|
|
7
|
+
// agentdoc.config.yaml.
|
|
8
|
+
//
|
|
9
|
+
// Two phases per unit:
|
|
10
|
+
// detect(repo, root) -> does this root host a unit of my kind?
|
|
11
|
+
// extract(ctx, unit) -> deterministic facts + provenance
|
|
12
|
+
//
|
|
13
|
+
// Adapters are pure: same checkout, same facts, same order.
|
|
14
|
+
export class Adapter {
|
|
15
|
+
constructor({ name, version = "1.0.0", kind = "generic" }) {
|
|
16
|
+
this.name = name;
|
|
17
|
+
this.version = version;
|
|
18
|
+
this.kind = kind; // generic | unit-marker | manifest | source | contract | ci
|
|
19
|
+
}
|
|
20
|
+
// Unit-marker adapters answer: does `root` contain a unit I recognise?
|
|
21
|
+
// Returning a marker path is what makes the root eligible.
|
|
22
|
+
detect(ctx, root) { return null; }
|
|
23
|
+
// Manifest/source adapters contribute facts for an already-eligible unit.
|
|
24
|
+
extract(ctx, unit) {}
|
|
25
|
+
// Contract adapters validate and index contract files listed by config.
|
|
26
|
+
contracts(ctx) { return []; }
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Client-of-resource resolution.
|
|
2
|
+
//
|
|
3
|
+
// A component that depends on a declared external service also *uses* the
|
|
4
|
+
// resource that service fronts, when exactly one authored Resource of the
|
|
5
|
+
// declared type exists. A message-bus client binds to the event bus; a cache
|
|
6
|
+
// client to the cache.
|
|
7
|
+
//
|
|
8
|
+
// The mapping is declared in configuration rather than inferred, because
|
|
9
|
+
// "this npm package talks to our bus" is a fact about the project, not about
|
|
10
|
+
// the package. When more than one Resource of the type exists the fact is left
|
|
11
|
+
// unresolved: an ambiguous match must never become an edge.
|
|
12
|
+
|
|
13
|
+
const POOL_HINT = /(createPool|new Pool|pg\.Pool|pgxpool|sql\.Open|mysql\.createConnection|Pool\(|DataSource|prisma\.(datasource|client)|DATABASE_URL|DB_DSN|redis|minio|Client\()/;
|
|
14
|
+
|
|
15
|
+
export function resolveClientResources(ctx, unit, compRef, provRec) {
|
|
16
|
+
const declared = ctx.externalMatchers().filter((x) => x.resourceType);
|
|
17
|
+
if (!declared.length) return [];
|
|
18
|
+
// The rule is "this unit depends on this service", so it is tested against
|
|
19
|
+
// the unit's production dependency names, not against its source text.
|
|
20
|
+
const deps = ctx.productionDependencies(compRef);
|
|
21
|
+
if (!deps.length) return [];
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const x of declared) {
|
|
24
|
+
if (!deps.some((d) => x.re.test(d))) continue;
|
|
25
|
+
const cands = ctx.sources.entities.filter(
|
|
26
|
+
(e) => e.kind === "Resource" && e.doc.spec.type === x.resourceType
|
|
27
|
+
);
|
|
28
|
+
if (cands.length !== 1) {
|
|
29
|
+
if (cands.length > 1) {
|
|
30
|
+
ctx.diagnostics.push({
|
|
31
|
+
severity: "warning",
|
|
32
|
+
code: "AGENTDOC_BINDING_AMBIGUOUS",
|
|
33
|
+
subject: compRef,
|
|
34
|
+
refs: [compRef],
|
|
35
|
+
message:
|
|
36
|
+
"component '" + compRef + "' depends on " + x.name + " and there are " + cands.length +
|
|
37
|
+
" authored Resources of type " + x.resourceType + "; the binding is left unresolved",
|
|
38
|
+
paths: [],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const target = cands[0];
|
|
44
|
+
// The matcher name is *data* — which declared client produced the edge — not
|
|
45
|
+
// a derivation route. Encoding it in `via` would put it outside the relation's
|
|
46
|
+
// identity, so a component depending on two matchers of the same Resource type
|
|
47
|
+
// would merge into one edge and the graph would state the dependency once
|
|
48
|
+
// instead of twice. `via` names the mechanism; `client` names the instance.
|
|
49
|
+
ctx.addEdge("usesResource", compRef, target.ref, { via: "declared-client", client: x.name }, [provRec], "DERIVED");
|
|
50
|
+
ctx.addFact(compRef, "resource.ownership", target.name, {
|
|
51
|
+
evidenceClass: "DERIVED",
|
|
52
|
+
confidence: "deterministic",
|
|
53
|
+
provRecs: [provRec],
|
|
54
|
+
semantics: "the component depends on " + x.name + ", declared as a client of the " + x.resourceType + " Resource",
|
|
55
|
+
});
|
|
56
|
+
out.push(target.ref);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { POOL_HINT };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Turborepo adapter: records task-graph driven verification commands and the
|
|
2
|
+
// pipeline they belong to. Turborepo knows how to run a task for one package
|
|
3
|
+
// and how to filter by dependency graph, which makes it the most precise
|
|
4
|
+
// verification answer available for a monorepo unit.
|
|
5
|
+
import { Adapter } from "./registry.mjs";
|
|
6
|
+
import { parseYamlDocuments } from "../core/yaml.mjs";
|
|
7
|
+
|
|
8
|
+
// Whether a declared task is a check is decided by `taskTier` alone, not by a
|
|
9
|
+
// second, narrower pattern. Two lists meant two places to disagree: a task that
|
|
10
|
+
// `TIERS` recognised — `e2e`, `playwright`, `drift:detect`, `generate:types` —
|
|
11
|
+
// was silently dropped because this filter had never heard of it, and the
|
|
12
|
+
// "untiered task" diagnostic could not fire for it either, since it sits after
|
|
13
|
+
// the filter. A verification entry that vanishes without a word is worse than
|
|
14
|
+
// one that is visibly untiered.
|
|
15
|
+
|
|
16
|
+
export class TurborepoAdapter extends Adapter {
|
|
17
|
+
static adapterName = "turborepo";
|
|
18
|
+
constructor() {
|
|
19
|
+
super({ name: "turborepo", version: "1.0.0", kind: "ci" });
|
|
20
|
+
}
|
|
21
|
+
detect(ctx, root) {
|
|
22
|
+
const p = root + "/turbo.json";
|
|
23
|
+
if (!ctx.repo.exists(p)) return null;
|
|
24
|
+
return { path: p, shape: "importable" };
|
|
25
|
+
}
|
|
26
|
+
extract(ctx, unit) {
|
|
27
|
+
const { repo, prov, compRef } = ctx;
|
|
28
|
+
const p = nearestTurboConfig(repo, unit.root);
|
|
29
|
+
if (!p) return {};
|
|
30
|
+
let doc;
|
|
31
|
+
try {
|
|
32
|
+
doc = JSON.parse(repo.readText(p));
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
// Every declared task is considered; `taskTier` decides which are checks.
|
|
37
|
+
// Filtering by a second pattern first is what silently dropped the tasks
|
|
38
|
+
// that pattern had never heard of.
|
|
39
|
+
const tasks = Object.keys(doc.tasks || doc.pipeline || {}).sort();
|
|
40
|
+
if (!tasks.length) return {};
|
|
41
|
+
const pr = prov.add("observed", p, "taskgraph-extractor", tasks.map((t) => "ver:tg:" + compRef + ":" + t));
|
|
42
|
+
const pkgName = unit.pkgName;
|
|
43
|
+
const scripts = Object.keys((unit.pkg || {}).scripts || {});
|
|
44
|
+
const emitted = [];
|
|
45
|
+
for (const t of tasks) {
|
|
46
|
+
// A task-graph command is only real if this unit actually declares the
|
|
47
|
+
// task. `turbo run test --filter=./apps/notifications` is not a command
|
|
48
|
+
// when that package has no `test` script, and emitting it would put an
|
|
49
|
+
// unrunnable command in front of an agent as if it were the answer.
|
|
50
|
+
if (!scripts.includes(t)) continue;
|
|
51
|
+
const tier = taskTier(t);
|
|
52
|
+
if (!tier) {
|
|
53
|
+
// A task with no tier is usually an ordinary package script (`dev`,
|
|
54
|
+
// `docs`, `release`) and not a missing verification entry, so it is
|
|
55
|
+
// dropped silently. A task that *looks* like a check but cannot be
|
|
56
|
+
// tiered is a gap worth reporting, and this is the only place that can
|
|
57
|
+
// tell the two apart.
|
|
58
|
+
// A check task with no tier would otherwise vanish with no trace, and a
|
|
59
|
+
// verification entry that is silently missing is worse than one that is
|
|
60
|
+
// visibly untiered: nothing is left to notice the gap.
|
|
61
|
+
ctx.diagnostics.push({
|
|
62
|
+
severity: "warning",
|
|
63
|
+
code: "AGENTDOC_VERIFICATION_UNTIERED",
|
|
64
|
+
subject: compRef,
|
|
65
|
+
refs: [compRef, p],
|
|
66
|
+
message:
|
|
67
|
+
"check task '" + t + "' has no recognisable tier and was not recorded as a verification entry. " +
|
|
68
|
+
"Name the task test/lint/typecheck/build/e2e/integration/contract, or extend the adapter.",
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const filter = pkgName ? " --filter=" + pkgName : " --filter=./" + unit.root;
|
|
73
|
+
ctx.addVerification({
|
|
74
|
+
id: "tg:" + compRef + ":" + t,
|
|
75
|
+
componentRefs: [compRef],
|
|
76
|
+
tier,
|
|
77
|
+
command: "turbo run " + t + filter,
|
|
78
|
+
configPaths: [p],
|
|
79
|
+
provRecs: [pr],
|
|
80
|
+
});
|
|
81
|
+
emitted.push(t);
|
|
82
|
+
}
|
|
83
|
+
for (const t of tasks) if (!emitted.includes(t)) pr.subjects.delete("ver:tg:" + compRef + ":" + t);
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
contracts() {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The tier is derived from the whole task name, never from a fragment of it. A
|
|
92
|
+
// mis-tiered check is worse than an absent one: it is copied into a change set
|
|
93
|
+
// as though it proved something it does not, and an agent will run the cheap
|
|
94
|
+
// thing and believe the expensive thing was covered.
|
|
95
|
+
// A watch task is an interactive session, not a check. Recording `test:watch`
|
|
96
|
+
// as a runnable verification command hands an agent a process that never exits.
|
|
97
|
+
const NOT_A_CHECK = /(^|[:_-])(watch|dev|serve|start|preview)([:_-]|$)/;
|
|
98
|
+
|
|
99
|
+
const TIERS = [
|
|
100
|
+
// Order matters: the most specific reading of a name wins. `e2e-lite` is an
|
|
101
|
+
// integration suite that happens to contain "e2e", so it must be read before
|
|
102
|
+
// the e2e row rather than after it.
|
|
103
|
+
[/(^|[:_-])e2e-lite([:_-]|$)/, "integration"],
|
|
104
|
+
[/(^|[:_-])(e2e|playwright|cypress|acceptance)([:_-]|$)/, "e2e"],
|
|
105
|
+
[/(^|[:_-])(contract|openapi|drift|schema)([:_-]|$)/, "contract"],
|
|
106
|
+
[/(^|[:_-])(integration|itest|e2e-lite)([:_-]|$)/, "integration"],
|
|
107
|
+
[/(^|[:_-])(lint|typecheck|check-types|type-check|golangci|vet|fmt|format|audit)([:_-]|$)/, "static"],
|
|
108
|
+
[/(^|[:_-])(unit|jest|vitest|mocha)([:_-]|$)/, "unit"],
|
|
109
|
+
[/(^|[:_-])(build|compile|generate|bundle)([:_-]|$)/, "build"],
|
|
110
|
+
// A bare `test`/`tests`/`verify`/`check` is a unit test by convention. A
|
|
111
|
+
// repository that disagrees should name the tier, as above. This row is last
|
|
112
|
+
// on purpose: `check:prettier` and `verify:lint` are formatting and lint
|
|
113
|
+
// checks wearing a `check` prefix, and reading them as unit tests would put a
|
|
114
|
+
// trivial command in front of an agent as if it covered behaviour.
|
|
115
|
+
[/(^|[:_-])(test|tests|verify|check)([:_-]|$)/, "unit"],
|
|
116
|
+
];
|
|
117
|
+
function taskTier(t) {
|
|
118
|
+
if (NOT_A_CHECK.test(t)) return null;
|
|
119
|
+
for (const [re, tier] of TIERS) if (re.test(t)) return tier;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A task graph normally lives at the workspace root, not in each package.
|
|
124
|
+
function nearestTurboConfig(repo, root) {
|
|
125
|
+
let dir = root;
|
|
126
|
+
for (let i = 0; i < 12; i++) {
|
|
127
|
+
const p = dir ? dir + "/turbo.json" : "turbo.json";
|
|
128
|
+
if (repo.exists(p)) return p;
|
|
129
|
+
if (!dir) break;
|
|
130
|
+
dir = dir.split("/").slice(0, -1).join("/");
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export { parseYamlDocuments };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// TypeScript adapter.
|
|
2
|
+
//
|
|
3
|
+
// Owns tsconfig.json as an importability marker, project references as a build
|
|
4
|
+
// relation source, and the framework route conventions that are genuinely
|
|
5
|
+
// TypeScript-ecosystem facts rather than project conventions.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Adapter } from "./registry.mjs";
|
|
8
|
+
import { parseYamlDocuments } from "../core/yaml.mjs";
|
|
9
|
+
|
|
10
|
+
export class TypeScriptAdapter extends Adapter {
|
|
11
|
+
static adapterName = "typescript";
|
|
12
|
+
constructor() {
|
|
13
|
+
super({ name: "typescript", version: "1.0.0", kind: "unit-marker" });
|
|
14
|
+
}
|
|
15
|
+
detect(ctx, root) {
|
|
16
|
+
const p = root + "/tsconfig.json";
|
|
17
|
+
if (!ctx.repo.exists(p)) return null;
|
|
18
|
+
return { path: p, shape: "importable" };
|
|
19
|
+
}
|
|
20
|
+
extract(ctx, unit) {
|
|
21
|
+
const { repo, prov, compRef } = ctx;
|
|
22
|
+
const p = unit.root + "/tsconfig.json";
|
|
23
|
+
if (!repo.exists(p)) return {};
|
|
24
|
+
const derived = { languages: new Set(["typescript"]), runtimes: new Set(["node"]) };
|
|
25
|
+
let ts;
|
|
26
|
+
try {
|
|
27
|
+
ts = parseTsconfig(repo, p);
|
|
28
|
+
} catch {
|
|
29
|
+
ts = {};
|
|
30
|
+
}
|
|
31
|
+
const pr = prov.add("observed", p, "tsconfig-extractor", ["der:" + compRef + ":/languages", "rel:pending"]);
|
|
32
|
+
|
|
33
|
+
// Project references: an explicit build-time dependency between units.
|
|
34
|
+
for (const refPath of (ts.references || [])) {
|
|
35
|
+
const targetDir = path.posix.normalize(path.posix.join(unit.root, refPath));
|
|
36
|
+
const target = ctx.unitForRoot(targetDir);
|
|
37
|
+
if (!target || !target.component) continue;
|
|
38
|
+
ctx.addEdge("buildDependsOn", compRef, target.component.ref, { via: "tsconfig-references" }, [pr], "DERIVED");
|
|
39
|
+
}
|
|
40
|
+
derived.tsconfig = { composite: Boolean(ts.compilerOptions?.composite), references: (ts.references || []).length };
|
|
41
|
+
|
|
42
|
+
// Route conventions of the file-based router ecosystem: a route file is a
|
|
43
|
+
// real HTTP surface and must be visible to the query surface.
|
|
44
|
+
const routes = [];
|
|
45
|
+
for (const base of ["src/app", "app", "src/pages", "pages"]) {
|
|
46
|
+
if (!repo.isDir(unit.root + "/" + base)) continue;
|
|
47
|
+
for (const f of repo.walk(unit.root + "/" + base)) {
|
|
48
|
+
const routePath = routeFor(unit.root, base, f);
|
|
49
|
+
if (!routePath) continue;
|
|
50
|
+
const rpr = prov.add("observed", f, "route-extractor", ["der:" + compRef + ":/routes"]);
|
|
51
|
+
routes.push({ path: f, route: routePath, provenanceIds: null, _provs: new Set([rpr]) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (routes.length) {
|
|
55
|
+
routes.sort((a, b) => (a.route < b.route ? -1 : 1));
|
|
56
|
+
derived.routes = routes;
|
|
57
|
+
// Published on the unit so other adapters can resolve a scheduled
|
|
58
|
+
// trigger to the component that actually serves the route.
|
|
59
|
+
unit.routes = routes;
|
|
60
|
+
}
|
|
61
|
+
return derived;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function routeFor(root, base, file) {
|
|
66
|
+
const rel = file.slice((root + "/" + base).length + 1);
|
|
67
|
+
if (base.endsWith("app")) {
|
|
68
|
+
if (!rel.endsWith("/route.ts") && !rel.endsWith("/route.tsx") && !rel.endsWith("/route.js")) return null;
|
|
69
|
+
const segs = rel.split("/").slice(0, -1);
|
|
70
|
+
if (!segs.length) return "/";
|
|
71
|
+
return "/" + segs.map((s) => (s.startsWith("(") && s.endsWith(")") ? "" : s.replace(/^\[\.\.\.(.+)\]$/, ":$1*").replace(/^\[(.+)\]$/, ":$1"))).join("/");
|
|
72
|
+
}
|
|
73
|
+
if (!rel.startsWith("api/") || !rel.endsWith(".ts")) return null;
|
|
74
|
+
return "/" + rel.slice(4).replace(/\.ts$/, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// tsconfig allows comments and trailing commas; strip them before JSON.parse.
|
|
78
|
+
function parseTsconfig(repo, p) {
|
|
79
|
+
let text = repo.readText(p);
|
|
80
|
+
text = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'\\])\/\/.*$/gm, "$1");
|
|
81
|
+
text = text.replace(/,(\s*[}\]])/g, "$1");
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { parseYamlDocuments };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Wrangler adapter (Cloudflare Workers / Pages).
|
|
2
|
+
//
|
|
3
|
+
// This adapter is where the most expensive lesson of the reference project
|
|
4
|
+
// lives. A committed deployment manifest is a *declared* schedule and a
|
|
5
|
+
// *declared* binding set. It is DERIVED evidence about what the repository
|
|
6
|
+
// intends, and nothing more. If a live scheduler disagrees — an hourly trigger
|
|
7
|
+
// with hour-3 gating where the manifest says "daily" — the disagreement has to
|
|
8
|
+
// reach the graph as a conflict, which only happens if the manifest claim is
|
|
9
|
+
// emitted as a fact on the same key the observation uses. So: no manifest value
|
|
10
|
+
// is ever reported as deployed truth.
|
|
11
|
+
import { Adapter } from "./registry.mjs";
|
|
12
|
+
import { parse as parseToml } from "../core/toml.mjs";
|
|
13
|
+
import { sourceFiles } from "../core/sourcescan.mjs";
|
|
14
|
+
|
|
15
|
+
const MANIFESTS = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"];
|
|
16
|
+
|
|
17
|
+
export class WranglerAdapter extends Adapter {
|
|
18
|
+
static adapterName = "wrangler";
|
|
19
|
+
constructor() {
|
|
20
|
+
super({ name: "wrangler", version: "1.0.0", kind: "unit-marker" });
|
|
21
|
+
}
|
|
22
|
+
detect(ctx, root) {
|
|
23
|
+
for (const n of MANIFESTS) {
|
|
24
|
+
if (ctx.repo.exists(root + "/" + n)) return { path: root + "/" + n, shape: "deployable" };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
extract(ctx, unit) {
|
|
29
|
+
const { repo, prov, compRef } = ctx;
|
|
30
|
+
const file = MANIFESTS.map((n) => unit.root + "/" + n).find((p) => repo.exists(p));
|
|
31
|
+
if (!file) return {};
|
|
32
|
+
let toml;
|
|
33
|
+
try {
|
|
34
|
+
toml = file.endsWith(".toml") ? parseToml(repo.readText(file), file) : stripJsonc(repo.readText(file));
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
const derived = { runtimes: new Set(["cloudflare-workers"]) };
|
|
39
|
+
if (toml.name) unit.platformName = String(toml.name);
|
|
40
|
+
const pr = prov.add("observed", file, "platform-manifest-extractor", ["der:" + compRef + ":/runtimes", "der:" + compRef + ":/bindings"]);
|
|
41
|
+
|
|
42
|
+
const bindings = [];
|
|
43
|
+
// The manifest makes ONE claim about the binding set, so the graph records
|
|
44
|
+
// one fact. Emitting a fact per binding would make a manifest that declares
|
|
45
|
+
// two bindings look like a contradiction against a runtime that reports one.
|
|
46
|
+
const bindingKinds = new Set();
|
|
47
|
+
const bindingNames = new Set();
|
|
48
|
+
const pushBinding = (kind, locator) => {
|
|
49
|
+
bindingKinds.add(kind);
|
|
50
|
+
if (locator && locator.binding) bindingNames.add(locator.binding);
|
|
51
|
+
bindings.push({ kind, locator, resolution: "resolved", provenanceIds: null, _provs: new Set([pr]) });
|
|
52
|
+
};
|
|
53
|
+
for (const kv of toml.kv_namespaces || []) pushBinding("kv", { binding: String(kv.binding || "") });
|
|
54
|
+
for (const d1 of toml.d1_databases || []) pushBinding("d1", { binding: String(d1.binding || "") });
|
|
55
|
+
for (const r2 of toml.r2_buckets || []) pushBinding("r2", { binding: String(r2.binding || "") });
|
|
56
|
+
for (const dofs of toml.durable_objects?.bindings || []) pushBinding("durable-object", { binding: String(dofs.name || "") });
|
|
57
|
+
for (const em of toml.send_email || []) pushBinding("email-routing", { binding: String(em.name || em.binding || "") });
|
|
58
|
+
for (const q of toml.queues?.producers || []) pushBinding("queue-producer", { binding: String(q.binding || "") });
|
|
59
|
+
for (const q of toml.queues?.consumers || []) pushBinding("queue-consumer", { binding: String(q.queue || q.binding || "") });
|
|
60
|
+
if (bindings.length) derived.bindings = bindings;
|
|
61
|
+
|
|
62
|
+
// Service bindings: a platform-level runtime call. Resolved in a second
|
|
63
|
+
// pass once every unit's platform name is known.
|
|
64
|
+
const pending = [];
|
|
65
|
+
for (const svc of toml.services || []) {
|
|
66
|
+
pushBinding("service-binding", { binding: String(svc.binding || ""), service: String(svc.service || "") });
|
|
67
|
+
pending.push({ binding: String(svc.binding || ""), service: String(svc.service || "") });
|
|
68
|
+
}
|
|
69
|
+
if (pending.length) {
|
|
70
|
+
derived.serviceBindings = pending;
|
|
71
|
+
ctx.deferSecondPass((pass) => {
|
|
72
|
+
for (const sb of pending) {
|
|
73
|
+
const target = pass.byPlatformName.get(sb.service);
|
|
74
|
+
if (target && target.component) {
|
|
75
|
+
const epr = prov.add("observed", file, "service-binding-extractor", ["rel:pending"]);
|
|
76
|
+
ctx.addEdge("runtimeCalls", compRef, target.component.ref,
|
|
77
|
+
{ transport: "service-binding", contractRef: file }, [epr], "DERIVED");
|
|
78
|
+
} else {
|
|
79
|
+
ctx.diagnostics.push({
|
|
80
|
+
severity: "warning",
|
|
81
|
+
code: "AGENTDOC_SERVICE_BINDING_UNRESOLVED",
|
|
82
|
+
subject: compRef,
|
|
83
|
+
refs: [compRef],
|
|
84
|
+
message: "service binding '" + sb.service + "' declared in " + file + " does not match any discovered component",
|
|
85
|
+
paths: [file],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (bindingNames.size) {
|
|
93
|
+
ctx.addFact(compRef, "binding.declared", [...bindingNames].sort(), {
|
|
94
|
+
evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
|
|
95
|
+
semantics: "the binding names the committed manifest declares for this component",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (bindingKinds.size) {
|
|
99
|
+
ctx.addFact(compRef, "binding.kind", [...bindingKinds].sort(), {
|
|
100
|
+
evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
|
|
101
|
+
semantics: "the kinds of platform binding the committed manifest declares for this component",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Scheduled triggers. Emitted as a DERIVED fact on schedule.cron, which is
|
|
106
|
+
// the same key a scheduler observation uses, so the two can be compared.
|
|
107
|
+
const crons = ((toml.triggers && toml.triggers.crons) || []).map(String).sort();
|
|
108
|
+
if (crons.length) {
|
|
109
|
+
derived.schedules = crons;
|
|
110
|
+
ctx.addFact(compRef, "schedule.cron", crons.join(","), {
|
|
111
|
+
evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
|
|
112
|
+
semantics: "cron expressions the committed deployment manifest declares for this component",
|
|
113
|
+
});
|
|
114
|
+
ctx.addFact(compRef, "schedule.enabled", true, {
|
|
115
|
+
evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
|
|
116
|
+
semantics: "the manifest declares a trigger, i.e. the job is intended to be enabled",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Job handlers: a cron URL variable names the endpoint a trigger calls.
|
|
121
|
+
// Again one claim, not one per variable.
|
|
122
|
+
const vars = toml.vars || {};
|
|
123
|
+
const declaredTargets = [];
|
|
124
|
+
for (const [varName, url] of Object.entries(vars)) {
|
|
125
|
+
if (typeof url !== "string") continue;
|
|
126
|
+
const m = /^https?:\/\/[^/]+(\/[^\s]*)$/.exec(url);
|
|
127
|
+
if (!m) continue;
|
|
128
|
+
declaredTargets.push({ var: varName, route: m[1] });
|
|
129
|
+
}
|
|
130
|
+
if (declaredTargets.length) {
|
|
131
|
+
derived.scheduleTargets = declaredTargets.slice().sort((a, b) => (a.route < b.route ? -1 : 1));
|
|
132
|
+
ctx.addFact(compRef, "schedule.target", [...new Set(declaredTargets.map((t) => t.route))].sort(), {
|
|
133
|
+
evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
|
|
134
|
+
semantics: "endpoints the committed manifest points scheduled triggers at",
|
|
135
|
+
});
|
|
136
|
+
// A schedule that points at a route this repository implements is a
|
|
137
|
+
// `schedules` relation. Without it, "who does this trigger call?" has no
|
|
138
|
+
// answer and an agent debugging a missed job has nothing to follow.
|
|
139
|
+
for (const t of declaredTargets) {
|
|
140
|
+
ctx.deferSecondPass(() => {
|
|
141
|
+
const owner = findRouteOwner(ctx, t.route, compRef);
|
|
142
|
+
if (owner) {
|
|
143
|
+
const rpr = prov.add("observed", file, "schedule-extractor", ["rel:pending"]);
|
|
144
|
+
const tpr = prov.add("observed", owner.file, "schedule-extractor", ["rel:pending"]);
|
|
145
|
+
ctx.addEdge("schedules", compRef, owner.ref, { via: "declared-trigger-target", route: t.route }, [rpr, tpr], "DERIVED");
|
|
146
|
+
} else {
|
|
147
|
+
ctx.diagnostics.push({
|
|
148
|
+
severity: "warning",
|
|
149
|
+
code: "AGENTDOC_SCHEDULE_UNRESOLVED",
|
|
150
|
+
subject: compRef,
|
|
151
|
+
refs: [compRef],
|
|
152
|
+
message: "scheduled trigger target '" + t.route + "' declared in " + file + " does not resolve to a route in any discovered component",
|
|
153
|
+
paths: [file],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return derived;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Resolve an absolute route path to the component that *serves* it.
|
|
164
|
+
//
|
|
165
|
+
// A route literal appears in more places than the component that owns it: every
|
|
166
|
+
// generated OpenAPI client embeds the path it calls. So candidates are ranked
|
|
167
|
+
// rather than counted, and only an unambiguous winner is used:
|
|
168
|
+
//
|
|
169
|
+
// 2 a file-based route whose path is this route
|
|
170
|
+
// 1 a route literal in hand-written source
|
|
171
|
+
// 0 a route literal in generated client code
|
|
172
|
+
//
|
|
173
|
+
// A tie at the top score is a genuine ambiguity and yields no relation.
|
|
174
|
+
function findRouteOwner(ctx, route, selfRef) {
|
|
175
|
+
const generated = (ctx.cfg.discovery.generatedClientPatterns || ["/gen/", ".gen.", "/generated/", "/generated/"]).map(
|
|
176
|
+
(p) => new RegExp(escapeRe(p))
|
|
177
|
+
);
|
|
178
|
+
const isGenerated = (f) => generated.some((re) => re.test(f));
|
|
179
|
+
const candidates = [];
|
|
180
|
+
for (const unit of ctx.discovery.eligible) {
|
|
181
|
+
if (!unit.component || unit.component.ref === selfRef) continue;
|
|
182
|
+
const viaPath = (unit.routes || []).find((r) => r.route === route);
|
|
183
|
+
if (viaPath) { candidates.push({ ref: unit.component.ref, file: viaPath.path, score: 2 }); continue; }
|
|
184
|
+
for (const f of sourceFiles(ctx.repo, unit.root, { test: false })) {
|
|
185
|
+
const text = ctx.repo.readText(f);
|
|
186
|
+
if (!text.includes(route)) continue;
|
|
187
|
+
if (!new RegExp("[\"'\`]" + escapeRe(route) + "[\"'\`]").test(text)) continue;
|
|
188
|
+
candidates.push({ ref: unit.component.ref, file: f, score: isGenerated(f) ? 0 : 1 });
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!candidates.length) return null;
|
|
193
|
+
const best = Math.max(...candidates.map((c) => c.score));
|
|
194
|
+
const winners = candidates.filter((c) => c.score === best);
|
|
195
|
+
const distinct = new Set(winners.map((c) => c.ref));
|
|
196
|
+
if (distinct.size !== 1) return null;
|
|
197
|
+
return winners[0];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function escapeRe(s) {
|
|
201
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stripJsonc(text) {
|
|
205
|
+
return JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'\\])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1"));
|
|
206
|
+
}
|