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,147 @@
|
|
|
1
|
+
// Unit discovery.
|
|
2
|
+
//
|
|
3
|
+
// The core has no opinion about folder names. Which roots are units comes from
|
|
4
|
+
// two inputs only: the configured globs, and whatever the enabled adapters
|
|
5
|
+
// recognise as a unit marker. A root is eligible when it is either explicitly
|
|
6
|
+
// configured (supplementalRoots) or carries a unit marker from an adapter.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
9
|
+
import { expandGlob, globToRegExp, assertGlob } from "../core/fsx.mjs";
|
|
10
|
+
|
|
11
|
+
// Directories that are never units. Overridable, because "infra" or "tools"
|
|
12
|
+
// may be a legitimate unit in another project.
|
|
13
|
+
export const DEFAULT_NOT_UNIT_ROOTS = ["agentdoc", "docs", "scripts", "node_modules", ".github"];
|
|
14
|
+
|
|
15
|
+
export function discoverUnits(repo, cfg, adapters) {
|
|
16
|
+
const notUnit = new Set(cfg.discovery.notUnitRoots || DEFAULT_NOT_UNIT_ROOTS);
|
|
17
|
+
const extraDeployable = new Set();
|
|
18
|
+
const extraImportable = new Set();
|
|
19
|
+
const units = new Map();
|
|
20
|
+
const get = (root) => {
|
|
21
|
+
if (!units.has(root)) {
|
|
22
|
+
units.set(root, { root, markers: [], deployable: false, importable: false, facts: {}, adapterFacts: {} });
|
|
23
|
+
}
|
|
24
|
+
return units.get(root);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Candidate roots, derived without any knowledge of the layout:
|
|
28
|
+
// - the parent directory of every component descriptor (a descriptor can
|
|
29
|
+
// only sit at a unit root, by the location rule);
|
|
30
|
+
// - whatever an adapter recognises from a workspace file;
|
|
31
|
+
// - whatever the configuration declares explicitly.
|
|
32
|
+
const candidates = new Set();
|
|
33
|
+
const descriptorRoots = new Set();
|
|
34
|
+
for (const g of cfg.discovery.componentDescriptors) {
|
|
35
|
+
assertGlob(g, "agentdoc config");
|
|
36
|
+
for (const f of expandGlob(repo, g)) {
|
|
37
|
+
const dir = path.posix.dirname(f);
|
|
38
|
+
if (!notUnit.has(dir) && repo.isDir(dir)) {
|
|
39
|
+
candidates.add(dir);
|
|
40
|
+
descriptorRoots.add(dir);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const r of cfg.discovery.supplementalRoots || []) candidates.add(r);
|
|
45
|
+
|
|
46
|
+
// Adapters may contribute roots the descriptor globs cannot know about: a
|
|
47
|
+
// Go workspace member, a workspace file member with no descriptor yet. The
|
|
48
|
+
// core stays ignorant of the layout; only adapters know the layout.
|
|
49
|
+
for (const a of adapters) {
|
|
50
|
+
if (!a.roots) continue;
|
|
51
|
+
for (const r of a.roots({ repo, cfg }) || []) {
|
|
52
|
+
if (typeof r === "string") candidates.add(r);
|
|
53
|
+
else if (r && typeof r === "object" && r.root) {
|
|
54
|
+
candidates.add(r.root);
|
|
55
|
+
if (r.deployable) extraDeployable.add(r.root);
|
|
56
|
+
if (r.importable) extraImportable.add(r.root);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const root of [...candidates].sort()) {
|
|
62
|
+
if (notUnit.has(root)) continue;
|
|
63
|
+
if (!repo.isDir(root)) continue;
|
|
64
|
+
const u = get(root);
|
|
65
|
+
u.explicit = (cfg.discovery.supplementalRoots || []).includes(root) || descriptorRoots.has(root);
|
|
66
|
+
if (extraDeployable.has(root)) u.deployable = true;
|
|
67
|
+
if (extraImportable.has(root)) u.importable = true;
|
|
68
|
+
for (const a of adapters) {
|
|
69
|
+
const marker = a.detect ? a.detect({ repo, cfg, root }, root) : null;
|
|
70
|
+
if (marker) {
|
|
71
|
+
u.markers.push({ adapter: a.name, path: marker.path || marker, shape: marker.shape || "both" });
|
|
72
|
+
const shape = markerShape(marker);
|
|
73
|
+
if (shape === "deployable" || shape === "both") u.deployable = true;
|
|
74
|
+
if (shape === "importable" || shape === "both") u.importable = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const list = [...units.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
|
|
80
|
+
const eligible = list.filter((u) => u.deployable || u.importable || u.explicit);
|
|
81
|
+
return { units: list, eligible };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function markerShape(marker) {
|
|
85
|
+
// Adapters may return {path, shape}. Default to "both" so a bare marker keeps
|
|
86
|
+
// the unit eligible without asserting artifact shape it cannot prove.
|
|
87
|
+
if (marker && typeof marker === "object") return marker.shape || "both";
|
|
88
|
+
return "both";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function descriptorDir(file) {
|
|
92
|
+
return path.posix.dirname(file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Exactly one descriptor per eligible unit, and no descriptor outside one.
|
|
96
|
+
export function matchComponents(eligible, componentEntities) {
|
|
97
|
+
const errors = [];
|
|
98
|
+
const byDir = new Map();
|
|
99
|
+
for (const e of componentEntities) {
|
|
100
|
+
const dir = descriptorDir(e.file);
|
|
101
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
102
|
+
byDir.get(dir).push(e);
|
|
103
|
+
}
|
|
104
|
+
for (const u of eligible) {
|
|
105
|
+
const descs = byDir.get(u.root) || [];
|
|
106
|
+
if (descs.length === 0) {
|
|
107
|
+
const why = u.markers.map((m) => m.adapter).join(", ") || "configured";
|
|
108
|
+
errors.push(new AgentDocError(
|
|
109
|
+
CODES.COVERAGE_ZERO,
|
|
110
|
+
"unit '" + u.root + "' is recognised (" + why + ") but has no component descriptor at " + u.root + "/<descriptor>",
|
|
111
|
+
{ ref: u.root }
|
|
112
|
+
));
|
|
113
|
+
} else if (descs.length > 1) {
|
|
114
|
+
errors.push(new AgentDocError(
|
|
115
|
+
CODES.COVERAGE_MULTI,
|
|
116
|
+
"unit '" + u.root + "' matches " + descs.length + " component descriptors",
|
|
117
|
+
{ path: descs[0].file }
|
|
118
|
+
));
|
|
119
|
+
} else {
|
|
120
|
+
u.component = descs[0];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const unitRoots = new Set(eligible.map((u) => u.root));
|
|
124
|
+
for (const e of componentEntities) {
|
|
125
|
+
const dir = descriptorDir(e.file);
|
|
126
|
+
if (unitRoots.has(dir)) continue;
|
|
127
|
+
// A descriptor is a human claim that a component exists. When no adapter
|
|
128
|
+
// can corroborate a deployable or importable artifact in that source root,
|
|
129
|
+
// the claim is unsupported. This is reported rather than ignored: it is
|
|
130
|
+
// either a missing adapter, a wrong descriptor location, or a component
|
|
131
|
+
// that no longer exists.
|
|
132
|
+
errors.push(new AgentDocError(
|
|
133
|
+
CODES.COMPONENT_UNCORROBORATED,
|
|
134
|
+
"component descriptor at '" + dir + "' is not matched by any recognised unit: " +
|
|
135
|
+
"no adapter found a deployable or importable artifact in that source root. " +
|
|
136
|
+
"Check the descriptor location, or enable the adapter for this technology.",
|
|
137
|
+
{ path: e.file, ref: e.ref }
|
|
138
|
+
));
|
|
139
|
+
}
|
|
140
|
+
return { errors };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Directories covered by configured globs, used by audit to spot documentation
|
|
144
|
+
// or contracts that no descriptor claims.
|
|
145
|
+
export function configuredGlobs(cfg) {
|
|
146
|
+
return [...cfg.discovery.componentDescriptors, ...cfg.discovery.centralDescriptors];
|
|
147
|
+
}
|
package/core/facts.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Fact store.
|
|
2
|
+
//
|
|
3
|
+
// Every claim that any evidence class makes about a (subject, key) pair lands
|
|
4
|
+
// here, whatever produced it. Keeping them in one place is what makes conflict
|
|
5
|
+
// detection uniform: a scheduled cron declared in a config file and a
|
|
6
|
+
// scheduled cron read out of a live scheduler are simply two facts on the same
|
|
7
|
+
// key, and the conflict engine has no way to accidentally treat one as the
|
|
8
|
+
// other.
|
|
9
|
+
import { assertFactKeyShape } from "./authority/facts.mjs";
|
|
10
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
11
|
+
|
|
12
|
+
export class FactStore {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.facts = [];
|
|
15
|
+
this.bySubjectKey = new Map();
|
|
16
|
+
}
|
|
17
|
+
add({
|
|
18
|
+
subject, key, value, evidenceClass, confidence, provRecs, semantics,
|
|
19
|
+
observed, review, source,
|
|
20
|
+
}) {
|
|
21
|
+
if (!assertFactKeyShape(key)) {
|
|
22
|
+
// A malformed key is a compiler defect, not user input, so it surfaces as
|
|
23
|
+
// a catalog error rather than an unhandled exception.
|
|
24
|
+
throw new AgentDocError(CODES.CONFIG, "malformed fact key " + JSON.stringify(key) + " for subject " + subject);
|
|
25
|
+
}
|
|
26
|
+
const id = "a-" + String(this.facts.length + 1).padStart(4, "0");
|
|
27
|
+
const fact = {
|
|
28
|
+
id,
|
|
29
|
+
subject,
|
|
30
|
+
key,
|
|
31
|
+
value,
|
|
32
|
+
evidenceClass,
|
|
33
|
+
confidence,
|
|
34
|
+
status: "unresolved",
|
|
35
|
+
provRecs: provRecs || [],
|
|
36
|
+
semantics: semantics || null,
|
|
37
|
+
observed: observed || null,
|
|
38
|
+
review: review || null,
|
|
39
|
+
source: source || null,
|
|
40
|
+
};
|
|
41
|
+
this.facts.push(fact);
|
|
42
|
+
const gk = subject + "\u0000" + key;
|
|
43
|
+
if (!this.bySubjectKey.has(gk)) this.bySubjectKey.set(gk, []);
|
|
44
|
+
this.bySubjectKey.get(gk).push(fact);
|
|
45
|
+
return fact;
|
|
46
|
+
}
|
|
47
|
+
groups() {
|
|
48
|
+
return [...this.bySubjectKey.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
|
49
|
+
}
|
|
50
|
+
forSubject(subject) {
|
|
51
|
+
return this.facts.filter((f) => f.subject === subject);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/core/fsx.mjs
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Repository access bound to the checkout root.
|
|
2
|
+
//
|
|
3
|
+
// Every byte the compiler reads flows through the read-path tracker, and every
|
|
4
|
+
// existence probe and directory listing is recorded. The complete input record
|
|
5
|
+
// is what the graph's inputHash is computed over, so freshness detection can
|
|
6
|
+
// never miss an input. This is the single most important property of the
|
|
7
|
+
// original system and is preserved verbatim.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
13
|
+
|
|
14
|
+
export function sha256Hex(buf) {
|
|
15
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isRepoPath(p) {
|
|
19
|
+
return (
|
|
20
|
+
typeof p === "string" &&
|
|
21
|
+
p.length > 0 &&
|
|
22
|
+
!p.startsWith("/") &&
|
|
23
|
+
!/^[A-Za-z]:[\\/]/.test(p) &&
|
|
24
|
+
!p.includes("\\") &&
|
|
25
|
+
!p.split("/").some((seg) => seg === ".." || seg === "." || seg === "") &&
|
|
26
|
+
!/\s/.test(p)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertRepoPath(p, ctx) {
|
|
31
|
+
if (!isRepoPath(p)) {
|
|
32
|
+
throw new AgentDocError(
|
|
33
|
+
CODES.PATH_ESCAPE,
|
|
34
|
+
"invalid repository path " + JSON.stringify(p) + ": paths must be relative, use '/', and contain no '..'",
|
|
35
|
+
{ path: ctx }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build/tooling output is never repository authority. Configurable per install
|
|
41
|
+
// so a project with e.g. "target" or "vendor" trees keeps them out too.
|
|
42
|
+
// Build and tooling output. Tooling caches and dependency directories only: a
|
|
43
|
+
// name like `build`, `out` or `tmp` is far more likely to be a real unit in some
|
|
44
|
+
// repository than a generated directory, and a repository with a deployable
|
|
45
|
+
// `build/` must still be describable. Add project-specific output directories
|
|
46
|
+
// through `discovery.generatedDirs` in the configuration.
|
|
47
|
+
export const DEFAULT_GENERATED_DIRS = [
|
|
48
|
+
".git", "node_modules", "vendor", "target", "dist", "coverage",
|
|
49
|
+
".next", ".nuxt", ".svelte-kit", ".turbo", ".wrangler", ".vercel", ".output",
|
|
50
|
+
".cache", ".venv", "__pycache__", ".pytest_cache", ".gradle", ".terraform",
|
|
51
|
+
".idea", ".vscode-test", "test-results", "playwright-report",
|
|
52
|
+
"storybook-static", ".agentdoc", ".dart_tool", ".tox", ".mypy_cache",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export class Repo {
|
|
56
|
+
constructor(root, opts = {}) {
|
|
57
|
+
this.root = path.resolve(root);
|
|
58
|
+
this.readPaths = new Map();
|
|
59
|
+
this.probes = new Map();
|
|
60
|
+
this.walks = new Map();
|
|
61
|
+
this.listings = new Map();
|
|
62
|
+
this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
|
|
63
|
+
this.noGit = Boolean(opts.noGit);
|
|
64
|
+
}
|
|
65
|
+
abs(rel) {
|
|
66
|
+
const a = path.resolve(this.root, rel);
|
|
67
|
+
if (a !== this.root && !a.startsWith(this.root + path.sep)) {
|
|
68
|
+
throw new AgentDocError(CODES.PATH_ESCAPE, "path escapes repository: " + rel, { path: rel });
|
|
69
|
+
}
|
|
70
|
+
return a;
|
|
71
|
+
}
|
|
72
|
+
exists(rel) {
|
|
73
|
+
const r = fs.existsSync(this.abs(rel));
|
|
74
|
+
this.probes.set(rel, r);
|
|
75
|
+
return r;
|
|
76
|
+
}
|
|
77
|
+
isDir(rel) {
|
|
78
|
+
try {
|
|
79
|
+
const r = fs.statSync(this.abs(rel)).isDirectory();
|
|
80
|
+
this.probes.set(rel, r);
|
|
81
|
+
return r;
|
|
82
|
+
} catch {
|
|
83
|
+
this.probes.set(rel, false);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
readBytes(rel, { track = true } = {}) {
|
|
88
|
+
const a = this.abs(rel);
|
|
89
|
+
let buf;
|
|
90
|
+
try {
|
|
91
|
+
buf = fs.readFileSync(a);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new AgentDocError(CODES.PATH_UNRESOLVED, "path does not resolve inside the repository: " + rel, { path: rel });
|
|
94
|
+
}
|
|
95
|
+
if (track) this.readPaths.set(rel, sha256Hex(buf));
|
|
96
|
+
return buf;
|
|
97
|
+
}
|
|
98
|
+
readText(rel, opts = {}) {
|
|
99
|
+
const buf = this.readBytes(rel, opts);
|
|
100
|
+
let text;
|
|
101
|
+
try {
|
|
102
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
103
|
+
} catch {
|
|
104
|
+
throw new AgentDocError(CODES.UTF8, "file is not valid UTF-8: " + rel, { path: rel });
|
|
105
|
+
}
|
|
106
|
+
if (text.startsWith("")) text = text.slice(1);
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
readJson(rel) {
|
|
110
|
+
return JSON.parse(this.readText(rel));
|
|
111
|
+
}
|
|
112
|
+
readJsonUntracked(rel) {
|
|
113
|
+
return JSON.parse(this.readText(rel, { track: false }));
|
|
114
|
+
}
|
|
115
|
+
// Recursive listing, byte-sorted, never following symlinks out of the repo.
|
|
116
|
+
walk(relDir, { exclude = () => false } = {}) {
|
|
117
|
+
const base = relDir ? this.abs(relDir) : this.root;
|
|
118
|
+
const out = [];
|
|
119
|
+
const stack = [base];
|
|
120
|
+
while (stack.length) {
|
|
121
|
+
const dir = stack.pop();
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
129
|
+
for (const ent of entries) {
|
|
130
|
+
if (this.generatedDirs.has(ent.name)) continue;
|
|
131
|
+
const full = path.join(dir, ent.name);
|
|
132
|
+
const rel = path.relative(this.root, full).split(path.sep).join("/");
|
|
133
|
+
if (ent.isSymbolicLink()) continue;
|
|
134
|
+
if (exclude(rel, ent)) continue;
|
|
135
|
+
if (ent.isDirectory()) stack.push(full);
|
|
136
|
+
else if (ent.isFile()) out.push(rel);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
out.sort();
|
|
140
|
+
this.walks.set(relDir || ".", out);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
// Files under a path or a path *prefix*. A contract is often named by a module
|
|
144
|
+
// prefix rather than a single file (`events.go`, `events/*.go`), and
|
|
145
|
+
// requiring an exact directory would reject a perfectly good contract.
|
|
146
|
+
filesWithPrefix(prefix) {
|
|
147
|
+
if (this.exists(prefix)) {
|
|
148
|
+
const abs = this.abs(prefix);
|
|
149
|
+
try {
|
|
150
|
+
if (fs.statSync(abs).isFile()) return [prefix];
|
|
151
|
+
if (fs.statSync(abs).isDirectory()) return this.walk(prefix);
|
|
152
|
+
} catch { /* fall through to the prefix walk */ }
|
|
153
|
+
}
|
|
154
|
+
const base = prefix.split("/").slice(0, -1).join("/");
|
|
155
|
+
const last = prefix.split("/").pop();
|
|
156
|
+
if (!last) return [];
|
|
157
|
+
return this.walk(base).filter((f) => {
|
|
158
|
+
const tail = f.slice(base ? base.length + 1 : 0);
|
|
159
|
+
return tail === last || tail.startsWith(last + ".");
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// A directory listing is a compiler input like any other, and is recorded so
|
|
163
|
+
// the input hash covers it. An untracked listing would let a change to a
|
|
164
|
+
// directory's contents escape freshness detection.
|
|
165
|
+
listDir(relDir) {
|
|
166
|
+
let names;
|
|
167
|
+
try {
|
|
168
|
+
names = fs.readdirSync(this.abs(relDir), { withFileTypes: true })
|
|
169
|
+
.map((e) => (e.isDirectory() ? e.name + "/" : e.name))
|
|
170
|
+
.sort();
|
|
171
|
+
} catch {
|
|
172
|
+
names = [];
|
|
173
|
+
}
|
|
174
|
+
this.listings.set(relDir, names);
|
|
175
|
+
return names;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
git(args) {
|
|
179
|
+
// stderr must be piped, not inherited: every caller treats a non-zero exit
|
|
180
|
+
// as a signal to degrade (unborn HEAD, detached worktree, missing remote),
|
|
181
|
+
// and execFileSync would otherwise print git's own "fatal:" line to the
|
|
182
|
+
// user's terminal while the command reports success.
|
|
183
|
+
return execFileSync("git", args, {
|
|
184
|
+
cwd: this.root,
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
187
|
+
}).trim();
|
|
188
|
+
}
|
|
189
|
+
hasGit() {
|
|
190
|
+
if (this.noGit) return false;
|
|
191
|
+
try {
|
|
192
|
+
const probe = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
193
|
+
cwd: this.root,
|
|
194
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
195
|
+
});
|
|
196
|
+
return probe.toString().trim().length > 0;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// A checkout without git — a source tarball, a vendored copy, a CI runner with
|
|
202
|
+
// no VCS — still compiles. Freshness then rests on the input hash alone, and
|
|
203
|
+
// the graph records a null commit. A missing VCS is not a reason to refuse to
|
|
204
|
+
// describe a repository.
|
|
205
|
+
headCommit() {
|
|
206
|
+
if (this.noGit || !this.hasGit()) return "0000000";
|
|
207
|
+
try {
|
|
208
|
+
return this.git(["rev-parse", "HEAD"]);
|
|
209
|
+
} catch {
|
|
210
|
+
return "0000000";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Git reports paths relative to the git toplevel, which may be above
|
|
214
|
+
// this.root when the cataloged project sits inside a larger checkout — a
|
|
215
|
+
// corpus fixture inside this repository, or a vendored sub-project. Anything
|
|
216
|
+
// outside this.root is not a catalog input and must not mark it dirty;
|
|
217
|
+
// anything inside must be re-keyed to the root-relative form the read
|
|
218
|
+
// tracker uses.
|
|
219
|
+
gitPrefix() {
|
|
220
|
+
if (this.noGit || !this.hasGit()) return null;
|
|
221
|
+
try {
|
|
222
|
+
return this.git(["rev-parse", "--show-prefix"]);
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
toRepoPaths(gitPaths) {
|
|
228
|
+
const prefix = this.gitPrefix();
|
|
229
|
+
if (prefix === null) return [];
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const p of gitPaths) {
|
|
232
|
+
if (prefix && !p.startsWith(prefix) && p !== prefix.slice(0, -1)) continue;
|
|
233
|
+
const rel = prefix ? p.slice(prefix.length) : p;
|
|
234
|
+
if (rel) out.push(rel);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
dirtyPaths() {
|
|
239
|
+
if (this.noGit || !this.hasGit()) return [];
|
|
240
|
+
let raw;
|
|
241
|
+
try {
|
|
242
|
+
raw = this.git(["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
243
|
+
} catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
const paths = [];
|
|
247
|
+
let skipNext = false;
|
|
248
|
+
for (const rec of raw.split("\0")) {
|
|
249
|
+
if (!rec) continue;
|
|
250
|
+
// In -z format a rename/copy is two records: `XY <to>` then a bare
|
|
251
|
+
// `<from>` with no status. The bare record must be consumed, not parsed
|
|
252
|
+
// as a status line.
|
|
253
|
+
if (skipNext) { skipNext = false; continue; }
|
|
254
|
+
if (rec[0] === "R" || rec[0] === "C" || rec[1] === "R" || rec[1] === "C") skipNext = true;
|
|
255
|
+
paths.push(rec.slice(3));
|
|
256
|
+
}
|
|
257
|
+
return this.toRepoPaths(paths);
|
|
258
|
+
}
|
|
259
|
+
// null on failure (bad ref, missing object store) so callers can tell
|
|
260
|
+
// "the diff failed" apart from "the diff is empty".
|
|
261
|
+
diffPaths(fromRef) {
|
|
262
|
+
if (this.noGit || !this.hasGit()) return null;
|
|
263
|
+
try {
|
|
264
|
+
return this.toRepoPaths(this.git(["diff", "--name-only", fromRef]).split("\n").filter(Boolean));
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Minimal glob: '*' matches within one segment, '**' matches any depth.
|
|
272
|
+
export function globToRegExp(glob) {
|
|
273
|
+
const segs = glob.split("/");
|
|
274
|
+
let re = "^";
|
|
275
|
+
segs.forEach((seg, i) => {
|
|
276
|
+
if (seg === "**") {
|
|
277
|
+
if (i > 0) re += "/";
|
|
278
|
+
re += i === segs.length - 1 ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (i > 0 && segs[i - 1] !== "**") re += "/";
|
|
282
|
+
re += seg
|
|
283
|
+
.split("")
|
|
284
|
+
.map((c) => {
|
|
285
|
+
if (c === "*") return "[^/]*";
|
|
286
|
+
if (c === "?") return "[^/]";
|
|
287
|
+
return c.replace(/[.+^{$}()|[\]\\]/g, "\\$&");
|
|
288
|
+
})
|
|
289
|
+
.join("");
|
|
290
|
+
});
|
|
291
|
+
re += "$";
|
|
292
|
+
return new RegExp(re);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function assertGlob(g, ctx) {
|
|
296
|
+
if (typeof g !== "string" || g.length === 0 || g.startsWith("/")) {
|
|
297
|
+
throw new AgentDocError(CODES.GLOB, "glob must be repository-relative: " + g, { path: ctx });
|
|
298
|
+
}
|
|
299
|
+
if (g.split("/").includes("..")) {
|
|
300
|
+
throw new AgentDocError(CODES.GLOB, "glob must not contain '..': " + g, { path: ctx });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function expandGlob(repo, glob) {
|
|
305
|
+
assertGlob(glob);
|
|
306
|
+
const re = globToRegExp(glob);
|
|
307
|
+
const firstStar = glob.search(/[*?]/);
|
|
308
|
+
const prefix = firstStar < 0 ? glob : glob.slice(0, firstStar);
|
|
309
|
+
const baseDir = prefix.includes("/") ? prefix.slice(0, prefix.lastIndexOf("/")) : "";
|
|
310
|
+
return repo.walk(baseDir).filter((f) => re.test(f)).sort();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Canonical input hash: version inputs first, then every input file as
|
|
314
|
+
// path+content records, then every existence probe and directory listing.
|
|
315
|
+
// Length-prefixed so no concatenation is ambiguous.
|
|
316
|
+
export function computeInputHash(repo, versionInputs) {
|
|
317
|
+
const h = createHash("sha256");
|
|
318
|
+
const feed = (buf) => {
|
|
319
|
+
const len = Buffer.alloc(8);
|
|
320
|
+
len.writeBigUInt64BE(BigInt(buf.length));
|
|
321
|
+
h.update(len);
|
|
322
|
+
h.update(buf);
|
|
323
|
+
};
|
|
324
|
+
for (const v of versionInputs) feed(Buffer.from(v, "utf8"));
|
|
325
|
+
for (const p of [...repo.readPaths.keys()].sort()) {
|
|
326
|
+
feed(Buffer.from("file:" + p, "utf8"));
|
|
327
|
+
feed(repo.readBytesUntracked ? repo.readBytesUntracked(p) : repo.readBytes(p, { track: false }));
|
|
328
|
+
}
|
|
329
|
+
for (const [p, r] of [...repo.probes.entries()].sort()) {
|
|
330
|
+
feed(Buffer.from("probe:" + p, "utf8"));
|
|
331
|
+
feed(Buffer.from(r ? "1" : "0", "utf8"));
|
|
332
|
+
}
|
|
333
|
+
for (const [d, files] of [...repo.walks.entries()].sort()) {
|
|
334
|
+
feed(Buffer.from("walk:" + d, "utf8"));
|
|
335
|
+
feed(Buffer.from(files.join("\n"), "utf8"));
|
|
336
|
+
}
|
|
337
|
+
for (const [d, names] of [...repo.listings.entries()].sort()) {
|
|
338
|
+
feed(Buffer.from("list:" + d, "utf8"));
|
|
339
|
+
feed(Buffer.from(names.join("\n"), "utf8"));
|
|
340
|
+
}
|
|
341
|
+
return "sha256:" + h.digest("hex");
|
|
342
|
+
}
|