loopgraph 0.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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/adapters/agenthub/extract.d.ts +63 -0
- package/dist/adapters/agenthub/extract.js +144 -0
- package/dist/adapters/agenthub/facts.d.ts +27 -0
- package/dist/adapters/agenthub/facts.js +89 -0
- package/dist/adapters/agenthub/index.d.ts +13 -0
- package/dist/adapters/agenthub/index.js +18 -0
- package/dist/adapters/registry.d.ts +4 -0
- package/dist/adapters/registry.js +17 -0
- package/dist/adapters/types.d.ts +40 -0
- package/dist/adapters/types.js +2 -0
- package/dist/cache/content-cache.d.ts +46 -0
- package/dist/cache/content-cache.js +123 -0
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +26 -0
- package/dist/cli/assets/agent-kit.d.ts +15 -0
- package/dist/cli/assets/agent-kit.js +167 -0
- package/dist/cli/commands/check.d.ts +38 -0
- package/dist/cli/commands/check.js +70 -0
- package/dist/cli/commands/import.d.ts +6 -0
- package/dist/cli/commands/import.js +34 -0
- package/dist/cli/commands/init.d.ts +13 -0
- package/dist/cli/commands/init.js +70 -0
- package/dist/cli/commands/inspect.d.ts +23 -0
- package/dist/cli/commands/inspect.js +35 -0
- package/dist/cli/commands/snapshot.d.ts +121 -0
- package/dist/cli/commands/snapshot.js +0 -0
- package/dist/cli/commands/view.d.ts +34 -0
- package/dist/cli/commands/view.js +45 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/run.d.ts +11 -0
- package/dist/cli/run.js +329 -0
- package/dist/loader/find-yaml-files.d.ts +26 -0
- package/dist/loader/find-yaml-files.js +51 -0
- package/dist/loader/index.d.ts +4 -0
- package/dist/loader/index.js +4 -0
- package/dist/loader/load-model.d.ts +32 -0
- package/dist/loader/load-model.js +54 -0
- package/dist/loader/model-graph.d.ts +43 -0
- package/dist/loader/model-graph.js +78 -0
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.js +53 -0
- package/dist/query/diff.d.ts +47 -0
- package/dist/query/diff.js +130 -0
- package/dist/query/effective-constraints.d.ts +48 -0
- package/dist/query/effective-constraints.js +81 -0
- package/dist/query/index.d.ts +7 -0
- package/dist/query/index.js +7 -0
- package/dist/query/queries.d.ts +111 -0
- package/dist/query/queries.js +172 -0
- package/dist/query/run-query.d.ts +16 -0
- package/dist/query/run-query.js +181 -0
- package/dist/query/side-channel.d.ts +25 -0
- package/dist/query/side-channel.js +46 -0
- package/dist/query/slice.d.ts +57 -0
- package/dist/query/slice.js +124 -0
- package/dist/query/summary.d.ts +9 -0
- package/dist/query/summary.js +180 -0
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema/model.d.ts +661 -0
- package/dist/schema/model.js +183 -0
- package/dist/schema/status.d.ts +18 -0
- package/dist/schema/status.js +28 -0
- package/dist/staleness.d.ts +57 -0
- package/dist/staleness.js +148 -0
- package/dist/validate/anchor.d.ts +4 -0
- package/dist/validate/anchor.js +32 -0
- package/dist/validate/baseline.d.ts +11 -0
- package/dist/validate/baseline.js +18 -0
- package/dist/validate/checks.d.ts +59 -0
- package/dist/validate/checks.js +303 -0
- package/dist/validate/index.d.ts +6 -0
- package/dist/validate/index.js +6 -0
- package/dist/validate/inv1/check.d.ts +36 -0
- package/dist/validate/inv1/check.js +96 -0
- package/dist/validate/inv1/config.d.ts +75 -0
- package/dist/validate/inv1/config.js +63 -0
- package/dist/validate/inv1/scan.d.ts +49 -0
- package/dist/validate/inv1/scan.js +172 -0
- package/dist/validate/t0.d.ts +27 -0
- package/dist/validate/t0.js +31 -0
- package/dist/validate/types.d.ts +16 -0
- package/dist/validate/types.js +4 -0
- package/dist/validate/unregistered.d.ts +70 -0
- package/dist/validate/unregistered.js +111 -0
- package/dist/views/flow-mermaid.d.ts +30 -0
- package/dist/views/flow-mermaid.js +89 -0
- package/dist/views/index.d.ts +3 -0
- package/dist/views/index.js +3 -0
- package/dist/views/validate-mermaid.d.ts +39 -0
- package/dist/views/validate-mermaid.js +110 -0
- package/package.json +46 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { allNodes } from "../loader/model-graph.js";
|
|
4
|
+
import { collectDanglingAppliesTo } from "../query/effective-constraints.js";
|
|
5
|
+
import { anchorFilePath, isValidAnchorFormat } from "./anchor.js";
|
|
6
|
+
/** Schema-parse failures collected during loadModel become blocking violations. */
|
|
7
|
+
export function checkSchema(load) {
|
|
8
|
+
return load.parseErrors.map((e) => ({
|
|
9
|
+
check: "schema",
|
|
10
|
+
severity: "error",
|
|
11
|
+
message: e.message,
|
|
12
|
+
file: e.file,
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Advisory: a file-per-node file's name (minus extension) must equal the id of
|
|
17
|
+
* the single node it holds (Decision 004 技术点 3 — promised for Phase 1,
|
|
18
|
+
* delivered here in A7). Only single-node files are checked; array files that
|
|
19
|
+
* group many nodes under a section name (loops/*.yaml) carry the section, not
|
|
20
|
+
* an id, and are correctly exempt (see LoadResult.singleNodeFiles). Warning,
|
|
21
|
+
* not error: a mismatch is a naming-hygiene slip a human should fix, not a
|
|
22
|
+
* reason to fail a build (the model still loads correctly).
|
|
23
|
+
*/
|
|
24
|
+
export function checkFilenameMatchesId(load) {
|
|
25
|
+
const violations = [];
|
|
26
|
+
for (const [file, id] of load.singleNodeFiles) {
|
|
27
|
+
const stem = basename(file).replace(/\.(ya?ml)$/i, "");
|
|
28
|
+
if (stem !== id) {
|
|
29
|
+
violations.push({
|
|
30
|
+
check: "filename-id",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `file-per-node file "${file}" holds node "${id}" but its filename stem is "${stem}" — rename to "${id}.yaml" (Decision 004 file-per-node convention)`,
|
|
33
|
+
file,
|
|
34
|
+
nodeId: id,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return violations;
|
|
39
|
+
}
|
|
40
|
+
/** Duplicate ids collected during loadModel become blocking violations. */
|
|
41
|
+
export function checkIdUniqueness(load) {
|
|
42
|
+
return load.duplicateIds.map((d) => ({
|
|
43
|
+
check: "id-uniqueness",
|
|
44
|
+
severity: "error",
|
|
45
|
+
message: `id "${d.id}" is declared more than once, in: ${d.files.join(", ")}`,
|
|
46
|
+
nodeId: d.id,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
/** Every anchor string a graph references: Loop.anchors, Junction.evidence[].anchor, Scenario.verified_by[]. */
|
|
50
|
+
function collectAnchors(graph) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const node of allNodes(graph)) {
|
|
53
|
+
if (node.kind === "loop") {
|
|
54
|
+
for (const anchor of node.anchors)
|
|
55
|
+
out.push({ nodeId: node.id, anchor });
|
|
56
|
+
}
|
|
57
|
+
else if (node.kind === "junction") {
|
|
58
|
+
for (const evidence of node.evidence)
|
|
59
|
+
out.push({ nodeId: node.id, anchor: evidence.anchor });
|
|
60
|
+
}
|
|
61
|
+
else if (node.kind === "scenario") {
|
|
62
|
+
for (const anchor of node.verified_by)
|
|
63
|
+
out.push({ nodeId: node.id, anchor });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Blocking: every anchor must be syntactically well-formed (file#symbol or table[.column]). */
|
|
69
|
+
export function checkAnchorFormat(graph) {
|
|
70
|
+
const violations = [];
|
|
71
|
+
for (const { nodeId, anchor } of collectAnchors(graph)) {
|
|
72
|
+
if (!isValidAnchorFormat(anchor)) {
|
|
73
|
+
violations.push({
|
|
74
|
+
check: "anchor-format",
|
|
75
|
+
severity: "error",
|
|
76
|
+
message: `anchor "${anchor}" on ${nodeId} is not a valid "path#symbol" or "table[.column]" anchor`,
|
|
77
|
+
nodeId,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return violations;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every id-shaped cross-reference a node carries, paired with which kind
|
|
85
|
+
* it must resolve to. This is deliberately exhaustive over every
|
|
86
|
+
* reference-shaped field in the schema (Flow.traverses/guarded_by/
|
|
87
|
+
* references/crosses, Junction.between/scenarios, Loop.parent/scenarios,
|
|
88
|
+
* Feature.contains) — a reference field added to the schema later and
|
|
89
|
+
* forgotten here would silently stop being checked, so keep this in sync
|
|
90
|
+
* with src/schema/model.ts whenever a new reference field is added.
|
|
91
|
+
*/
|
|
92
|
+
function collectReferences(graph) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const node of allNodes(graph)) {
|
|
95
|
+
if (node.kind === "feature") {
|
|
96
|
+
for (const id of node.contains)
|
|
97
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "flow", field: "contains" });
|
|
98
|
+
}
|
|
99
|
+
else if (node.kind === "flow") {
|
|
100
|
+
for (const id of node.traverses)
|
|
101
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "loop", field: "traverses" });
|
|
102
|
+
for (const id of node.guarded_by)
|
|
103
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "loop", field: "guarded_by" });
|
|
104
|
+
for (const id of node.references)
|
|
105
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "flow", field: "references" });
|
|
106
|
+
for (const id of node.crosses)
|
|
107
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "junction", field: "crosses" });
|
|
108
|
+
}
|
|
109
|
+
else if (node.kind === "loop") {
|
|
110
|
+
if (node.parent)
|
|
111
|
+
out.push({ nodeId: node.id, targetId: node.parent, targetKind: "loop", field: "parent" });
|
|
112
|
+
for (const id of node.scenarios)
|
|
113
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "scenario", field: "scenarios" });
|
|
114
|
+
}
|
|
115
|
+
else if (node.kind === "junction") {
|
|
116
|
+
for (const id of node.between)
|
|
117
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "loop", field: "between" });
|
|
118
|
+
for (const id of node.scenarios)
|
|
119
|
+
out.push({ nodeId: node.id, targetId: id, targetKind: "scenario", field: "scenarios" });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Blocking: every cross-node reference (Flow.traverses, Junction.between,
|
|
126
|
+
* Loop.parent, etc.) must resolve to an actual node of the expected kind
|
|
127
|
+
* in the loaded graph. This is pure set-membership over an already-loaded
|
|
128
|
+
* graph — no I/O, no AST — so it stays within T0's cost budget despite
|
|
129
|
+
* catching what would otherwise be a silent dangling/typo'd reference
|
|
130
|
+
* (e.g. a Flow.traverses entry that matches the LoopId regex shape but
|
|
131
|
+
* names a Loop that was never actually defined).
|
|
132
|
+
*/
|
|
133
|
+
export function checkReferentialIntegrity(graph) {
|
|
134
|
+
const violations = [];
|
|
135
|
+
for (const { nodeId, targetId, targetKind, field } of collectReferences(graph)) {
|
|
136
|
+
if (!graph.byKind[targetKind].has(targetId)) {
|
|
137
|
+
violations.push({
|
|
138
|
+
check: "referential-integrity",
|
|
139
|
+
severity: "error",
|
|
140
|
+
message: `${nodeId}.${field} references "${targetId}", which is not a defined ${targetKind}`,
|
|
141
|
+
nodeId,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Scenario.applies_to.nodes is checked separately: unlike every other
|
|
146
|
+
// reference field, it's valid against ANY of the 6 kinds (Decision
|
|
147
|
+
// record 004, 技术点 2), so it doesn't fit collectReferences' one-
|
|
148
|
+
// targetKind-per-field shape.
|
|
149
|
+
for (const { scenarioId, targetId } of collectDanglingAppliesTo(graph)) {
|
|
150
|
+
violations.push({
|
|
151
|
+
check: "referential-integrity",
|
|
152
|
+
severity: "error",
|
|
153
|
+
message: `${scenarioId}.applies_to.nodes references "${targetId}", which is not a defined node of any kind`,
|
|
154
|
+
nodeId: scenarioId,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return violations;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Standard DFS back-edge cycle detection over a directed adjacency map.
|
|
161
|
+
* Returns every distinct cycle found, each as a node-id path that starts
|
|
162
|
+
* and ends on the same id (e.g. ["L1a", "L1b", "L1a"]). Duplicate
|
|
163
|
+
* reports of the same cycle entered from different starting nodes are
|
|
164
|
+
* collapsed by rotating each cycle to start at its lexicographically
|
|
165
|
+
* smallest id before deduping.
|
|
166
|
+
*/
|
|
167
|
+
function findCycles(adjacency) {
|
|
168
|
+
const color = new Map();
|
|
169
|
+
const stack = [];
|
|
170
|
+
const found = [];
|
|
171
|
+
function visit(node) {
|
|
172
|
+
color.set(node, "gray");
|
|
173
|
+
stack.push(node);
|
|
174
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
175
|
+
if (color.get(next) === "gray") {
|
|
176
|
+
const idx = stack.indexOf(next);
|
|
177
|
+
found.push([...stack.slice(idx), next]);
|
|
178
|
+
}
|
|
179
|
+
else if (color.get(next) !== "black") {
|
|
180
|
+
visit(next);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
stack.pop();
|
|
184
|
+
color.set(node, "black");
|
|
185
|
+
}
|
|
186
|
+
for (const node of adjacency.keys()) {
|
|
187
|
+
if (!color.has(node))
|
|
188
|
+
visit(node);
|
|
189
|
+
}
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const deduped = [];
|
|
192
|
+
for (const cycle of found) {
|
|
193
|
+
const ring = cycle.slice(0, -1); // drop the repeated closing id
|
|
194
|
+
let minIdx = 0;
|
|
195
|
+
for (let i = 1; i < ring.length; i++) {
|
|
196
|
+
const candidate = ring[i];
|
|
197
|
+
const current = ring[minIdx];
|
|
198
|
+
if (candidate !== undefined && current !== undefined && candidate < current)
|
|
199
|
+
minIdx = i;
|
|
200
|
+
}
|
|
201
|
+
const canonical = [...ring.slice(minIdx), ...ring.slice(0, minIdx)];
|
|
202
|
+
const key = canonical.join("->");
|
|
203
|
+
if (seen.has(key))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(key);
|
|
206
|
+
const first = canonical[0];
|
|
207
|
+
if (first === undefined)
|
|
208
|
+
continue; // ring/canonical structurally non-empty; guard, not assertion
|
|
209
|
+
deduped.push([...canonical, first]);
|
|
210
|
+
}
|
|
211
|
+
return deduped;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The only two fields in the schema that can form a cycle: `Loop.parent`
|
|
215
|
+
* and `Flow.references` both point at their own kind. Every other
|
|
216
|
+
* reference field is a bipartite edge into a different kind with no edge
|
|
217
|
+
* pointing back (see checkGraphAcyclic's docstring for the full
|
|
218
|
+
* argument), so a cycle is structurally impossible through them today.
|
|
219
|
+
* Adding ANY new node-reference field to the schema — same-kind or
|
|
220
|
+
* cross-kind — requires re-deriving that argument from scratch and
|
|
221
|
+
* updating this list if it turns out to close a cycle.
|
|
222
|
+
*/
|
|
223
|
+
const CYCLE_EDGE_SPECS = {
|
|
224
|
+
loop: {
|
|
225
|
+
field: "parent",
|
|
226
|
+
targets: (nodeId, graph) => {
|
|
227
|
+
const parent = graph.byKind.loop.get(nodeId)?.parent;
|
|
228
|
+
return parent ? [parent] : [];
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
flow: {
|
|
232
|
+
field: "references",
|
|
233
|
+
targets: (nodeId, graph) => graph.byKind.flow.get(nodeId)?.references ?? [],
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Blocking: neither `Loop.parent` nor `Flow.references` may form a
|
|
238
|
+
* cycle. These are the only two same-kind reference fields in the
|
|
239
|
+
* schema; every other reference field (Flow.traverses/guarded_by/
|
|
240
|
+
* crosses, Junction.between/scenarios, Loop.scenarios, Feature.contains)
|
|
241
|
+
* is a bipartite edge into a *different* kind, and that target kind
|
|
242
|
+
* carries no reference field pointing back to the source kind (Scenario
|
|
243
|
+
* has no node-reference outputs at all; Loop's only outputs are `parent`
|
|
244
|
+
* and `scenarios`; Junction's only outputs are `between` and
|
|
245
|
+
* `scenarios`) — so no cross-kind cycle is reachable through the current
|
|
246
|
+
* schema. This is a claim about today's schema shape, not a structural
|
|
247
|
+
* guarantee: adding any new node-reference field (same-kind OR
|
|
248
|
+
* cross-kind) invalidates it and requires re-deriving which edges can
|
|
249
|
+
* now close a cycle.
|
|
250
|
+
*/
|
|
251
|
+
export function checkGraphAcyclic(graph) {
|
|
252
|
+
const violations = [];
|
|
253
|
+
for (const [kind, spec] of Object.entries(CYCLE_EDGE_SPECS)) {
|
|
254
|
+
const adjacency = new Map();
|
|
255
|
+
for (const id of graph.byKind[kind].keys()) {
|
|
256
|
+
adjacency.set(id, spec.targets(id, graph));
|
|
257
|
+
}
|
|
258
|
+
for (const cycle of findCycles(adjacency)) {
|
|
259
|
+
const nodeId = cycle[0]; // findCycles never returns an empty ring; guard rather than assert
|
|
260
|
+
if (nodeId === undefined)
|
|
261
|
+
continue;
|
|
262
|
+
violations.push({
|
|
263
|
+
check: "graph-acyclic",
|
|
264
|
+
severity: "error",
|
|
265
|
+
message: `${kind}.${spec.field} forms a cycle: ${cycle.join(" -> ")}`,
|
|
266
|
+
nodeId,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return violations;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Advisory by default: does the anchored file exist under `repoRoot`?
|
|
274
|
+
* This deliberately does NOT check the symbol resolves inside the file —
|
|
275
|
+
* that needs real AST parsing (Phase 2 adapter work). It also does not
|
|
276
|
+
* fail the build by default (severity: "warning") because a target repo's
|
|
277
|
+
* HEAD legitimately moves past whatever commit the model was authored
|
|
278
|
+
* against; treat failures here as "investigate: mis-encoded vs. code
|
|
279
|
+
* moved on", not as a hard gate. Pass `strict: true` to promote to error
|
|
280
|
+
* once a repo pins anchors to a known-good commit.
|
|
281
|
+
*/
|
|
282
|
+
export async function checkAnchorExistence(graph, repoRoot, options = {}) {
|
|
283
|
+
const violations = [];
|
|
284
|
+
const severity = options.strict ? "error" : "warning";
|
|
285
|
+
for (const { nodeId, anchor } of collectAnchors(graph)) {
|
|
286
|
+
const filePath = anchorFilePath(anchor);
|
|
287
|
+
if (!filePath)
|
|
288
|
+
continue; // table-style anchors have no file to check
|
|
289
|
+
const exists = await access(join(repoRoot, filePath))
|
|
290
|
+
.then(() => true)
|
|
291
|
+
.catch(() => false);
|
|
292
|
+
if (!exists) {
|
|
293
|
+
violations.push({
|
|
294
|
+
check: "anchor-existence",
|
|
295
|
+
severity,
|
|
296
|
+
message: `anchor "${anchor}" on ${nodeId} points at "${filePath}", which does not exist under ${repoRoot}`,
|
|
297
|
+
nodeId,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return violations;
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=checks.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Violation } from "../types.js";
|
|
2
|
+
import type { Inv1Config } from "./config.js";
|
|
3
|
+
import { type WritePoint } from "./scan.js";
|
|
4
|
+
export interface Inv1CheckResult {
|
|
5
|
+
/** false when the scan couldn't run (no git checkout) — a loud skip, not a silent pass. */
|
|
6
|
+
ran: boolean;
|
|
7
|
+
skippedReason?: string;
|
|
8
|
+
/** Every classified write point (allowed + violation + unanalyzable) — the inventory for human verification. */
|
|
9
|
+
writePoints: WritePoint[];
|
|
10
|
+
candidateFiles: number;
|
|
11
|
+
filesScanned: number;
|
|
12
|
+
timingMs: number;
|
|
13
|
+
}
|
|
14
|
+
export interface Inv1CheckOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Incremental mode (B2): when set, only candidate files also in this set are
|
|
17
|
+
* scanned — the diff closure, not the whole repo. `candidateFiles` still
|
|
18
|
+
* reports the full candidate count so the ratio (scanned/candidate) is visible.
|
|
19
|
+
*/
|
|
20
|
+
onlyFiles?: Set<string>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs the INV-1 canonical-writer scan over `repoRoot` using `config`. Kept
|
|
24
|
+
* OUT of `runT0` on purpose (see t0.ts docstring): it needs a whole-repo scan,
|
|
25
|
+
* so it is timed and gated independently. The scan primitive it calls is pure;
|
|
26
|
+
* only the candidate selection + file reads here do I/O.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runInv1Check(repoRoot: string, config: Inv1Config, options?: Inv1CheckOptions): Promise<Inv1CheckResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Maps scan write points to T0 violations: a `violation` verdict is a blocking
|
|
31
|
+
* error (a guarded column written from outside the allowlist); `unanalyzable`
|
|
32
|
+
* is an advisory warning (surfaced for human confirmation, never silently
|
|
33
|
+
* passed); `allowed` points are inventory only, not violations.
|
|
34
|
+
*/
|
|
35
|
+
export declare function inv1ViolationsFrom(result: Inv1CheckResult): Violation[];
|
|
36
|
+
//# sourceMappingURL=check.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { scanFileForGuardedWrites } from "./scan.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Candidate pre-filter (proposal 001 §6 / 006 A6: "grep 预筛候选文件,再对候选做
|
|
9
|
+
* AST 确认"). `git grep -l` is the impure narrowing step (fast, tracked files
|
|
10
|
+
* only, respects .gitignore); the pure AST scan then confirms each candidate.
|
|
11
|
+
* Returns undefined when the target is not a usable git checkout.
|
|
12
|
+
*
|
|
13
|
+
* Pattern `update\(` (not `\.update\(`) so a destructured/re-exported binding
|
|
14
|
+
* `const { update } = db; update(runs)…` still lands a file in the candidate
|
|
15
|
+
* set — under-matching here would be a silent miss, over-matching only costs a
|
|
16
|
+
* cheap extra AST parse. The one residual is a *renamed* method
|
|
17
|
+
* (`const u = db.update`), which no grep pattern for `update(` can catch — a
|
|
18
|
+
* documented limitation (does not occur in the target today).
|
|
19
|
+
*/
|
|
20
|
+
async function gitGrepCandidates(repoRoot) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync("git", ["grep", "-lE", "update\\(", "--", "*.ts", ":!*.test.ts", ":!*.spec.ts"], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 });
|
|
23
|
+
return stdout
|
|
24
|
+
.split("\n")
|
|
25
|
+
.map((l) => l.trim())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
// git grep exits 1 when there are simply NO matches — an empty candidate
|
|
30
|
+
// set, not a failure. Any other exit (128 = not a repo, ENOENT = no git)
|
|
31
|
+
// is a real "cannot run" and surfaces as a skip.
|
|
32
|
+
if (err.code === 1)
|
|
33
|
+
return [];
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Runs the INV-1 canonical-writer scan over `repoRoot` using `config`. Kept
|
|
39
|
+
* OUT of `runT0` on purpose (see t0.ts docstring): it needs a whole-repo scan,
|
|
40
|
+
* so it is timed and gated independently. The scan primitive it calls is pure;
|
|
41
|
+
* only the candidate selection + file reads here do I/O.
|
|
42
|
+
*/
|
|
43
|
+
export async function runInv1Check(repoRoot, config, options = {}) {
|
|
44
|
+
const start = performance.now();
|
|
45
|
+
const candidates = await gitGrepCandidates(repoRoot);
|
|
46
|
+
if (candidates === undefined) {
|
|
47
|
+
return {
|
|
48
|
+
ran: false,
|
|
49
|
+
skippedReason: "INV-1 scan needs a git checkout (git grep unavailable at repo-root)",
|
|
50
|
+
writePoints: [],
|
|
51
|
+
candidateFiles: 0,
|
|
52
|
+
filesScanned: 0,
|
|
53
|
+
timingMs: performance.now() - start,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const toScan = options.onlyFiles
|
|
57
|
+
? candidates.filter((c) => options.onlyFiles?.has(c))
|
|
58
|
+
: candidates;
|
|
59
|
+
const writePoints = [];
|
|
60
|
+
let filesScanned = 0;
|
|
61
|
+
for (const rel of toScan) {
|
|
62
|
+
const content = await readFile(join(repoRoot, rel), "utf8").catch(() => undefined);
|
|
63
|
+
if (content === undefined)
|
|
64
|
+
continue; // a listed-but-unreadable file (race/symlink) — skip, don't crash
|
|
65
|
+
filesScanned++;
|
|
66
|
+
writePoints.push(...scanFileForGuardedWrites(rel, content, config));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
ran: true,
|
|
70
|
+
writePoints,
|
|
71
|
+
candidateFiles: candidates.length,
|
|
72
|
+
filesScanned,
|
|
73
|
+
timingMs: performance.now() - start,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Maps scan write points to T0 violations: a `violation` verdict is a blocking
|
|
78
|
+
* error (a guarded column written from outside the allowlist); `unanalyzable`
|
|
79
|
+
* is an advisory warning (surfaced for human confirmation, never silently
|
|
80
|
+
* passed); `allowed` points are inventory only, not violations.
|
|
81
|
+
*/
|
|
82
|
+
export function inv1ViolationsFrom(result) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const wp of result.writePoints) {
|
|
85
|
+
if (wp.verdict === "allowed")
|
|
86
|
+
continue;
|
|
87
|
+
out.push({
|
|
88
|
+
check: "inv1-write-site",
|
|
89
|
+
severity: wp.verdict === "violation" ? "error" : "warning",
|
|
90
|
+
message: `${wp.reason} — ${wp.filePath}:${wp.line} (${wp.snippet})`,
|
|
91
|
+
file: wp.filePath,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=check.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* INV-1 (canonical-writer) enforcement config, per proposal 001 §6 and
|
|
4
|
+
* Proposal 006 A6. Lives in the TARGET repo (or, pre-GATE-1, in the agenthub
|
|
5
|
+
* adapter's example dir) so the engine hard-codes NO business paths — it only
|
|
6
|
+
* provides the generic "guarded-column write-site scan + location allowlist"
|
|
7
|
+
* primitive; which tables/columns are guarded and which paths may write them
|
|
8
|
+
* is data supplied here.
|
|
9
|
+
*
|
|
10
|
+
* Shipped as JSON (not the plan's `loopgraph.config.ts`) deliberately: T0 must
|
|
11
|
+
* stay <5s and must not compile TypeScript in its hot path, and JSON needs no
|
|
12
|
+
* TS-loader dependency. How config ultimately lands in the target repo (`.ts` vs `.json`,
|
|
13
|
+
* repo location) is a GATE-1 (A8) decision — A6 does not pre-empt it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const GuardedTableConfig: z.ZodObject<{
|
|
16
|
+
/** Guarded state columns (drizzle property names), e.g. ["status"]. */
|
|
17
|
+
columns: z.ZodArray<z.ZodString, "many">;
|
|
18
|
+
/** Repo-relative path prefixes whose files MAY write these columns (the canonical writer). */
|
|
19
|
+
allowlist: z.ZodArray<z.ZodString, "many">;
|
|
20
|
+
}, "strip", z.ZodTypeAny, {
|
|
21
|
+
columns: string[];
|
|
22
|
+
allowlist: string[];
|
|
23
|
+
}, {
|
|
24
|
+
columns: string[];
|
|
25
|
+
allowlist: string[];
|
|
26
|
+
}>;
|
|
27
|
+
export type GuardedTableConfig = z.infer<typeof GuardedTableConfig>;
|
|
28
|
+
export declare const Inv1Config: z.ZodObject<{
|
|
29
|
+
/** Keyed by the drizzle table identifier as written in code (e.g. `runs`, `sessions`). */
|
|
30
|
+
guardedTables: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
31
|
+
/** Guarded state columns (drizzle property names), e.g. ["status"]. */
|
|
32
|
+
columns: z.ZodArray<z.ZodString, "many">;
|
|
33
|
+
/** Repo-relative path prefixes whose files MAY write these columns (the canonical writer). */
|
|
34
|
+
allowlist: z.ZodArray<z.ZodString, "many">;
|
|
35
|
+
}, "strip", z.ZodTypeAny, {
|
|
36
|
+
columns: string[];
|
|
37
|
+
allowlist: string[];
|
|
38
|
+
}, {
|
|
39
|
+
columns: string[];
|
|
40
|
+
allowlist: string[];
|
|
41
|
+
}>>;
|
|
42
|
+
/** Alias identifier → canonical guarded-table identifier (for `import { runs as runsTable }`). */
|
|
43
|
+
aliases: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
44
|
+
/**
|
|
45
|
+
* Path prefixes where a dynamic-table `update(<expr>).set(...)` is known to be
|
|
46
|
+
* a table-agnostic generic primitive (e.g. `packages/db`), so it is NOT
|
|
47
|
+
* surfaced as unanalyzable. Everything else dynamic outside the allowlist is.
|
|
48
|
+
*/
|
|
49
|
+
unanalyzableExceptions: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
guardedTables: Record<string, {
|
|
52
|
+
columns: string[];
|
|
53
|
+
allowlist: string[];
|
|
54
|
+
}>;
|
|
55
|
+
aliases: Record<string, string>;
|
|
56
|
+
unanalyzableExceptions: string[];
|
|
57
|
+
}, {
|
|
58
|
+
guardedTables: Record<string, {
|
|
59
|
+
columns: string[];
|
|
60
|
+
allowlist: string[];
|
|
61
|
+
}>;
|
|
62
|
+
aliases?: Record<string, string> | undefined;
|
|
63
|
+
unanalyzableExceptions?: string[] | undefined;
|
|
64
|
+
}>;
|
|
65
|
+
export type Inv1Config = z.infer<typeof Inv1Config>;
|
|
66
|
+
export interface LoadInv1ConfigResult {
|
|
67
|
+
/** Parsed config, or undefined when no config file is present (INV-1 simply not run). */
|
|
68
|
+
config?: Inv1Config;
|
|
69
|
+
/** Set when a config file exists but is malformed — a loud error, not a silent skip. */
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
export declare const INV1_CONFIG_FILENAME = "loopgraph.config.json";
|
|
73
|
+
/** Loads and validates `<dir>/loopgraph.config.json`. Absent file → `{}` (skip); present+invalid → `{error}`. */
|
|
74
|
+
export declare function loadInv1Config(dir: string): Promise<LoadInv1ConfigResult>;
|
|
75
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
/**
|
|
5
|
+
* INV-1 (canonical-writer) enforcement config, per proposal 001 §6 and
|
|
6
|
+
* Proposal 006 A6. Lives in the TARGET repo (or, pre-GATE-1, in the agenthub
|
|
7
|
+
* adapter's example dir) so the engine hard-codes NO business paths — it only
|
|
8
|
+
* provides the generic "guarded-column write-site scan + location allowlist"
|
|
9
|
+
* primitive; which tables/columns are guarded and which paths may write them
|
|
10
|
+
* is data supplied here.
|
|
11
|
+
*
|
|
12
|
+
* Shipped as JSON (not the plan's `loopgraph.config.ts`) deliberately: T0 must
|
|
13
|
+
* stay <5s and must not compile TypeScript in its hot path, and JSON needs no
|
|
14
|
+
* TS-loader dependency. How config ultimately lands in the target repo (`.ts` vs `.json`,
|
|
15
|
+
* repo location) is a GATE-1 (A8) decision — A6 does not pre-empt it.
|
|
16
|
+
*/
|
|
17
|
+
export const GuardedTableConfig = z.object({
|
|
18
|
+
/** Guarded state columns (drizzle property names), e.g. ["status"]. */
|
|
19
|
+
columns: z.array(z.string().min(1)).min(1),
|
|
20
|
+
/** Repo-relative path prefixes whose files MAY write these columns (the canonical writer). */
|
|
21
|
+
allowlist: z.array(z.string().min(1)).min(1),
|
|
22
|
+
});
|
|
23
|
+
export const Inv1Config = z.object({
|
|
24
|
+
/** Keyed by the drizzle table identifier as written in code (e.g. `runs`, `sessions`). */
|
|
25
|
+
guardedTables: z.record(z.string(), GuardedTableConfig),
|
|
26
|
+
/** Alias identifier → canonical guarded-table identifier (for `import { runs as runsTable }`). */
|
|
27
|
+
aliases: z.record(z.string(), z.string()).default({}),
|
|
28
|
+
/**
|
|
29
|
+
* Path prefixes where a dynamic-table `update(<expr>).set(...)` is known to be
|
|
30
|
+
* a table-agnostic generic primitive (e.g. `packages/db`), so it is NOT
|
|
31
|
+
* surfaced as unanalyzable. Everything else dynamic outside the allowlist is.
|
|
32
|
+
*/
|
|
33
|
+
unanalyzableExceptions: z.array(z.string()).default([]),
|
|
34
|
+
});
|
|
35
|
+
export const INV1_CONFIG_FILENAME = "loopgraph.config.json";
|
|
36
|
+
/** Loads and validates `<dir>/loopgraph.config.json`. Absent file → `{}` (skip); present+invalid → `{error}`. */
|
|
37
|
+
export async function loadInv1Config(dir) {
|
|
38
|
+
const path = join(dir, INV1_CONFIG_FILENAME);
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(path, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {}; // no config → INV-1 not configured for this target
|
|
45
|
+
}
|
|
46
|
+
let json;
|
|
47
|
+
try {
|
|
48
|
+
json = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return {
|
|
52
|
+
error: `${INV1_CONFIG_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const parsed = Inv1Config.safeParse(json);
|
|
56
|
+
if (!parsed.success) {
|
|
57
|
+
return {
|
|
58
|
+
error: `${INV1_CONFIG_FILENAME} failed schema validation: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { config: parsed.data };
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Inv1Config } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* INV-1 write-site scanner (Proposal 006 A6). A PURE function of
|
|
4
|
+
* `(filePath, content, config)` — no file I/O, no git, no TS type-checking.
|
|
5
|
+
* It parses `content` with `ts.createSourceFile` (a pure in-memory syntax
|
|
6
|
+
* parse) and walks the AST for drizzle guarded-column write expressions.
|
|
7
|
+
*
|
|
8
|
+
* Purity is load-bearing: it is the precondition for the B3 machine-level
|
|
9
|
+
* cache, whose key is `hash(adapter_version, blob_oid, config_hash)` — same
|
|
10
|
+
* bytes + same config ⇒ same result, so config is an explicit parameter, never
|
|
11
|
+
* read from disk inside here.
|
|
12
|
+
*
|
|
13
|
+
* Why AST, not regex (acceptance (a) = no false-negatives): a regex can only
|
|
14
|
+
* find the forms it anticipates, so an unrecognized guarded write becomes a
|
|
15
|
+
* SILENT miss. Walking the AST classifies every guarded-table write chain, so
|
|
16
|
+
* anything not confidently analyzable falls into `unanalyzable` by
|
|
17
|
+
* construction — the conservative, human-review bucket. Chain SHAPE also
|
|
18
|
+
* disambiguates drizzle `db.update(runs).set({...})` from lookalikes like
|
|
19
|
+
* `hash.update(x).digest()` (no `.set`) without needing types.
|
|
20
|
+
*
|
|
21
|
+
* Recognized guarded-table transition-write forms:
|
|
22
|
+
* - `update(TABLE).set(...)`
|
|
23
|
+
* - `insert(TABLE)…onConflictDoUpdate({ set: {...} })` (an upsert IS a
|
|
24
|
+
* transition, so it is classified, not silently skipped)
|
|
25
|
+
* TABLE is resolved from a bare identifier via: config.guardedTables →
|
|
26
|
+
* config.aliases → file-local `const X = <guardedTable>` aliases (pre-scanned).
|
|
27
|
+
*
|
|
28
|
+
* Documented residual false-negatives (no data-flow/type analysis, on purpose,
|
|
29
|
+
* to keep the parse pure and within T0's <5s budget): a guarded table reached
|
|
30
|
+
* through a *renamed method* (`const u = db.update; u(runs)`) or a *reassigned /
|
|
31
|
+
* parameter-passed* table binding is not resolved. Pure `insert(TABLE).values`
|
|
32
|
+
* (row creation, i.e. INITIAL state, not a transition) is deliberately out of
|
|
33
|
+
* INV-1's transition scope. None of these forms occur in the current target today (verified);
|
|
34
|
+
* recorded here rather than left silent.
|
|
35
|
+
*/
|
|
36
|
+
export type WriteVerdict = "allowed" | "violation" | "unanalyzable";
|
|
37
|
+
export interface WritePoint {
|
|
38
|
+
filePath: string;
|
|
39
|
+
/** 1-based line, for a human reading the report — deliberately NOT used as a stable anchor. */
|
|
40
|
+
line: number;
|
|
41
|
+
/** Resolved guarded table identifier, or null for a dynamic (non-identifier) table expression. */
|
|
42
|
+
table: string | null;
|
|
43
|
+
columns: string[] | "opaque";
|
|
44
|
+
verdict: WriteVerdict;
|
|
45
|
+
reason: string;
|
|
46
|
+
snippet: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function scanFileForGuardedWrites(filePath: string, content: string, config: Inv1Config): WritePoint[];
|
|
49
|
+
//# sourceMappingURL=scan.d.ts.map
|