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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/adapters/agenthub/extract.d.ts +63 -0
  4. package/dist/adapters/agenthub/extract.js +144 -0
  5. package/dist/adapters/agenthub/facts.d.ts +27 -0
  6. package/dist/adapters/agenthub/facts.js +89 -0
  7. package/dist/adapters/agenthub/index.d.ts +13 -0
  8. package/dist/adapters/agenthub/index.js +18 -0
  9. package/dist/adapters/registry.d.ts +4 -0
  10. package/dist/adapters/registry.js +17 -0
  11. package/dist/adapters/types.d.ts +40 -0
  12. package/dist/adapters/types.js +2 -0
  13. package/dist/cache/content-cache.d.ts +46 -0
  14. package/dist/cache/content-cache.js +123 -0
  15. package/dist/cli/args.d.ts +7 -0
  16. package/dist/cli/args.js +26 -0
  17. package/dist/cli/assets/agent-kit.d.ts +15 -0
  18. package/dist/cli/assets/agent-kit.js +167 -0
  19. package/dist/cli/commands/check.d.ts +38 -0
  20. package/dist/cli/commands/check.js +70 -0
  21. package/dist/cli/commands/import.d.ts +6 -0
  22. package/dist/cli/commands/import.js +34 -0
  23. package/dist/cli/commands/init.d.ts +13 -0
  24. package/dist/cli/commands/init.js +70 -0
  25. package/dist/cli/commands/inspect.d.ts +23 -0
  26. package/dist/cli/commands/inspect.js +35 -0
  27. package/dist/cli/commands/snapshot.d.ts +121 -0
  28. package/dist/cli/commands/snapshot.js +0 -0
  29. package/dist/cli/commands/view.d.ts +34 -0
  30. package/dist/cli/commands/view.js +45 -0
  31. package/dist/cli/index.d.ts +3 -0
  32. package/dist/cli/index.js +19 -0
  33. package/dist/cli/run.d.ts +11 -0
  34. package/dist/cli/run.js +329 -0
  35. package/dist/loader/find-yaml-files.d.ts +26 -0
  36. package/dist/loader/find-yaml-files.js +51 -0
  37. package/dist/loader/index.d.ts +4 -0
  38. package/dist/loader/index.js +4 -0
  39. package/dist/loader/load-model.d.ts +32 -0
  40. package/dist/loader/load-model.js +54 -0
  41. package/dist/loader/model-graph.d.ts +43 -0
  42. package/dist/loader/model-graph.js +78 -0
  43. package/dist/mcp/server.d.ts +5 -0
  44. package/dist/mcp/server.js +53 -0
  45. package/dist/query/diff.d.ts +47 -0
  46. package/dist/query/diff.js +130 -0
  47. package/dist/query/effective-constraints.d.ts +48 -0
  48. package/dist/query/effective-constraints.js +81 -0
  49. package/dist/query/index.d.ts +7 -0
  50. package/dist/query/index.js +7 -0
  51. package/dist/query/queries.d.ts +111 -0
  52. package/dist/query/queries.js +172 -0
  53. package/dist/query/run-query.d.ts +16 -0
  54. package/dist/query/run-query.js +181 -0
  55. package/dist/query/side-channel.d.ts +25 -0
  56. package/dist/query/side-channel.js +46 -0
  57. package/dist/query/slice.d.ts +57 -0
  58. package/dist/query/slice.js +124 -0
  59. package/dist/query/summary.d.ts +9 -0
  60. package/dist/query/summary.js +180 -0
  61. package/dist/schema/index.d.ts +3 -0
  62. package/dist/schema/index.js +3 -0
  63. package/dist/schema/model.d.ts +661 -0
  64. package/dist/schema/model.js +183 -0
  65. package/dist/schema/status.d.ts +18 -0
  66. package/dist/schema/status.js +28 -0
  67. package/dist/staleness.d.ts +57 -0
  68. package/dist/staleness.js +148 -0
  69. package/dist/validate/anchor.d.ts +4 -0
  70. package/dist/validate/anchor.js +32 -0
  71. package/dist/validate/baseline.d.ts +11 -0
  72. package/dist/validate/baseline.js +18 -0
  73. package/dist/validate/checks.d.ts +59 -0
  74. package/dist/validate/checks.js +303 -0
  75. package/dist/validate/index.d.ts +6 -0
  76. package/dist/validate/index.js +6 -0
  77. package/dist/validate/inv1/check.d.ts +36 -0
  78. package/dist/validate/inv1/check.js +96 -0
  79. package/dist/validate/inv1/config.d.ts +75 -0
  80. package/dist/validate/inv1/config.js +63 -0
  81. package/dist/validate/inv1/scan.d.ts +49 -0
  82. package/dist/validate/inv1/scan.js +172 -0
  83. package/dist/validate/t0.d.ts +27 -0
  84. package/dist/validate/t0.js +31 -0
  85. package/dist/validate/types.d.ts +16 -0
  86. package/dist/validate/types.js +4 -0
  87. package/dist/validate/unregistered.d.ts +70 -0
  88. package/dist/validate/unregistered.js +111 -0
  89. package/dist/views/flow-mermaid.d.ts +30 -0
  90. package/dist/views/flow-mermaid.js +89 -0
  91. package/dist/views/index.d.ts +3 -0
  92. package/dist/views/index.js +3 -0
  93. package/dist/views/validate-mermaid.d.ts +39 -0
  94. package/dist/views/validate-mermaid.js +110 -0
  95. 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,6 @@
1
+ export * from "./types.js";
2
+ export * from "./anchor.js";
3
+ export * from "./checks.js";
4
+ export * from "./baseline.js";
5
+ export * from "./t0.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./anchor.js";
3
+ export * from "./checks.js";
4
+ export * from "./baseline.js";
5
+ export * from "./t0.js";
6
+ //# sourceMappingURL=index.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