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,51 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const MODEL_DIR_READ_HINTS = {
|
|
4
|
+
ENOENT: `not found — run "loopgraph init" and "loopgraph import" first`,
|
|
5
|
+
ENOTDIR: "not a directory — check the target path",
|
|
6
|
+
EACCES: "not readable (permission denied)",
|
|
7
|
+
EPERM: "not readable (permission denied)",
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Pure error-code → message mapping, factored out of findYamlFiles so
|
|
11
|
+
* every branch (ENOENT/ENOTDIR/EACCES/EPERM/unknown) is directly
|
|
12
|
+
* unit-testable without needing to actually reproduce each condition on
|
|
13
|
+
* a real filesystem — reproducing EACCES portably (across macOS/Linux/CI
|
|
14
|
+
* containers, some of which run as root and ignore chmod) is exactly the
|
|
15
|
+
* kind of flaky test this sidesteps. Returns undefined for an
|
|
16
|
+
* unrecognized/missing error code, signaling "rethrow as-is".
|
|
17
|
+
*/
|
|
18
|
+
export function formatModelDirReadError(dir, code) {
|
|
19
|
+
const hint = code ? MODEL_DIR_READ_HINTS[code] : undefined;
|
|
20
|
+
return hint ? `model directory "${dir}" is ${hint}` : undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* An unreadable model directory (missing, wrong permissions, or not
|
|
24
|
+
* actually a directory) is a setup error, not "zero nodes". Treating it
|
|
25
|
+
* as an empty model would make `runT0` trivially pass with zero
|
|
26
|
+
* violations — a false "T0 passed" that implies something was checked
|
|
27
|
+
* when nothing was. This surfaces a clear, actionable error instead of
|
|
28
|
+
* whatever raw errno a bare `readdir` would throw.
|
|
29
|
+
*
|
|
30
|
+
* Lives in its own module (not inlined in load-model.ts) because it's
|
|
31
|
+
* reused by src/staleness.ts (computeModelContentHash needs the same
|
|
32
|
+
* "every *.yaml/*.yml file under a model dir" listing to hash) — a
|
|
33
|
+
* second, unrelated caller that has no business depending on
|
|
34
|
+
* `loadModel`'s own orchestration module.
|
|
35
|
+
*/
|
|
36
|
+
export async function findYamlFiles(dir) {
|
|
37
|
+
let dirents;
|
|
38
|
+
try {
|
|
39
|
+
dirents = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const message = formatModelDirReadError(dir, err.code);
|
|
43
|
+
if (message)
|
|
44
|
+
throw new Error(message);
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
return dirents
|
|
48
|
+
.filter((d) => d.isFile() && /\.ya?ml$/.test(d.name))
|
|
49
|
+
.map((d) => join(d.parentPath ?? d.path, d.name));
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=find-yaml-files.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type DuplicateId, type ModelEntry, type ModelGraph } from "./model-graph.js";
|
|
2
|
+
export interface ParseError {
|
|
3
|
+
file: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LoadResult {
|
|
7
|
+
graph: ModelGraph;
|
|
8
|
+
entries: ModelEntry[];
|
|
9
|
+
parseErrors: ParseError[];
|
|
10
|
+
duplicateIds: DuplicateId[];
|
|
11
|
+
/**
|
|
12
|
+
* Files written as a SINGLE node (not a YAML array), mapped to that node's
|
|
13
|
+
* id. This is exactly the set the file-per-node convention applies to
|
|
14
|
+
* (Decision 004 技术点 3: filename must equal id) — array files that group
|
|
15
|
+
* many nodes under a section name (the seed's loops/*.yaml) are deliberately
|
|
16
|
+
* absent, so `checkFilenameMatchesId` doesn't false-flag them.
|
|
17
|
+
*/
|
|
18
|
+
singleNodeFiles: Map<string, string>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Loads every *.yaml/*.yml file under `dir`. A file may contain a single
|
|
22
|
+
* node or an array of nodes; array elements are validated independently
|
|
23
|
+
* so one malformed sibling doesn't take down the rest of the file's
|
|
24
|
+
* otherwise-valid nodes (a whole-array `safeParse` would fail the entire
|
|
25
|
+
* batch on a single bad element — that's the wrong blast radius for a
|
|
26
|
+
* file that groups many unrelated loops together, see
|
|
27
|
+
* examples/agenthub/model/loops/*.yaml). Parse/validation failures are
|
|
28
|
+
* collected rather than thrown so `loopgraph check` can report every
|
|
29
|
+
* broken node in one pass instead of stopping at the first one.
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadModel(dir: string): Promise<LoadResult>;
|
|
32
|
+
//# sourceMappingURL=load-model.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { ModelNode } from "../schema/index.js";
|
|
5
|
+
import { findYamlFiles } from "./find-yaml-files.js";
|
|
6
|
+
import { buildGraph } from "./model-graph.js";
|
|
7
|
+
/**
|
|
8
|
+
* Loads every *.yaml/*.yml file under `dir`. A file may contain a single
|
|
9
|
+
* node or an array of nodes; array elements are validated independently
|
|
10
|
+
* so one malformed sibling doesn't take down the rest of the file's
|
|
11
|
+
* otherwise-valid nodes (a whole-array `safeParse` would fail the entire
|
|
12
|
+
* batch on a single bad element — that's the wrong blast radius for a
|
|
13
|
+
* file that groups many unrelated loops together, see
|
|
14
|
+
* examples/agenthub/model/loops/*.yaml). Parse/validation failures are
|
|
15
|
+
* collected rather than thrown so `loopgraph check` can report every
|
|
16
|
+
* broken node in one pass instead of stopping at the first one.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadModel(dir) {
|
|
19
|
+
const files = await findYamlFiles(dir);
|
|
20
|
+
const entries = [];
|
|
21
|
+
const parseErrors = [];
|
|
22
|
+
const singleNodeFiles = new Map();
|
|
23
|
+
for (const absPath of files.sort()) {
|
|
24
|
+
const relPath = relative(dir, absPath);
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
const text = await readFile(absPath, "utf8");
|
|
28
|
+
raw = parseYaml(text);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
parseErrors.push({ file: relPath, message: `YAML parse error: ${err.message}` });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const isArrayFile = Array.isArray(raw);
|
|
35
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
36
|
+
items.forEach((item, index) => {
|
|
37
|
+
const result = ModelNode.safeParse(item);
|
|
38
|
+
if (!result.success) {
|
|
39
|
+
const location = isArrayFile ? `${relPath} (item ${index})` : relPath;
|
|
40
|
+
parseErrors.push({
|
|
41
|
+
file: location,
|
|
42
|
+
message: `schema validation failed: ${result.error.message}`,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
entries.push({ node: result.data, file: relPath });
|
|
47
|
+
if (!isArrayFile)
|
|
48
|
+
singleNodeFiles.set(relPath, result.data.id);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const { graph, duplicateIds } = buildGraph(entries);
|
|
52
|
+
return { graph, entries, parseErrors, duplicateIds, singleNodeFiles };
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=load-model.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DebtEntry, Feature, Flow, Junction, Loop, ModelNode, Scenario } from "../schema/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* A single successfully-parsed node together with the file it came from.
|
|
4
|
+
* This is the raw ingredient both the graph builder and the T0 checker
|
|
5
|
+
* (ID uniqueness in particular) need — keeping it as a flat list first
|
|
6
|
+
* means "two nodes share an id" is representable before we collapse
|
|
7
|
+
* anything into a Map.
|
|
8
|
+
*/
|
|
9
|
+
export interface ModelEntry {
|
|
10
|
+
node: ModelNode;
|
|
11
|
+
file: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ModelGraph {
|
|
14
|
+
byKind: {
|
|
15
|
+
feature: Map<string, Feature>;
|
|
16
|
+
flow: Map<string, Flow>;
|
|
17
|
+
loop: Map<string, Loop>;
|
|
18
|
+
junction: Map<string, Junction>;
|
|
19
|
+
scenario: Map<string, Scenario>;
|
|
20
|
+
debt: Map<string, DebtEntry>;
|
|
21
|
+
};
|
|
22
|
+
/** Source file each id was loaded from (first occurrence wins on duplicates). */
|
|
23
|
+
sourceFile: Map<string, string>;
|
|
24
|
+
}
|
|
25
|
+
export interface DuplicateId {
|
|
26
|
+
id: string;
|
|
27
|
+
files: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function createEmptyGraph(): ModelGraph;
|
|
30
|
+
/**
|
|
31
|
+
* Builds a ModelGraph from a flat list of parsed entries. Duplicate ids
|
|
32
|
+
* (same id appearing in more than one entry, whether same kind or not)
|
|
33
|
+
* are reported rather than silently overwritten — first occurrence wins
|
|
34
|
+
* in the graph, but every duplicate is surfaced so T0's ID-uniqueness
|
|
35
|
+
* check can fail the build.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildGraph(entries: ModelEntry[]): {
|
|
38
|
+
graph: ModelGraph;
|
|
39
|
+
duplicateIds: DuplicateId[];
|
|
40
|
+
};
|
|
41
|
+
export declare function getNode(graph: ModelGraph, id: string): ModelNode | undefined;
|
|
42
|
+
export declare function allNodes(graph: ModelGraph): ModelNode[];
|
|
43
|
+
//# sourceMappingURL=model-graph.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function createEmptyGraph() {
|
|
2
|
+
return {
|
|
3
|
+
byKind: {
|
|
4
|
+
feature: new Map(),
|
|
5
|
+
flow: new Map(),
|
|
6
|
+
loop: new Map(),
|
|
7
|
+
junction: new Map(),
|
|
8
|
+
scenario: new Map(),
|
|
9
|
+
debt: new Map(),
|
|
10
|
+
},
|
|
11
|
+
sourceFile: new Map(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Builds a ModelGraph from a flat list of parsed entries. Duplicate ids
|
|
16
|
+
* (same id appearing in more than one entry, whether same kind or not)
|
|
17
|
+
* are reported rather than silently overwritten — first occurrence wins
|
|
18
|
+
* in the graph, but every duplicate is surfaced so T0's ID-uniqueness
|
|
19
|
+
* check can fail the build.
|
|
20
|
+
*/
|
|
21
|
+
export function buildGraph(entries) {
|
|
22
|
+
const graph = createEmptyGraph();
|
|
23
|
+
const filesById = new Map();
|
|
24
|
+
for (const { node, file } of entries) {
|
|
25
|
+
const existing = filesById.get(node.id);
|
|
26
|
+
if (existing) {
|
|
27
|
+
existing.push(file);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
filesById.set(node.id, [file]);
|
|
31
|
+
graph.sourceFile.set(node.id, file);
|
|
32
|
+
switch (node.kind) {
|
|
33
|
+
case "feature":
|
|
34
|
+
graph.byKind.feature.set(node.id, node);
|
|
35
|
+
break;
|
|
36
|
+
case "flow":
|
|
37
|
+
graph.byKind.flow.set(node.id, node);
|
|
38
|
+
break;
|
|
39
|
+
case "loop":
|
|
40
|
+
graph.byKind.loop.set(node.id, node);
|
|
41
|
+
break;
|
|
42
|
+
case "junction":
|
|
43
|
+
graph.byKind.junction.set(node.id, node);
|
|
44
|
+
break;
|
|
45
|
+
case "scenario":
|
|
46
|
+
graph.byKind.scenario.set(node.id, node);
|
|
47
|
+
break;
|
|
48
|
+
case "debt":
|
|
49
|
+
graph.byKind.debt.set(node.id, node);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const duplicateIds = [];
|
|
54
|
+
for (const [id, files] of filesById) {
|
|
55
|
+
if (files.length > 1)
|
|
56
|
+
duplicateIds.push({ id, files });
|
|
57
|
+
}
|
|
58
|
+
return { graph, duplicateIds };
|
|
59
|
+
}
|
|
60
|
+
export function getNode(graph, id) {
|
|
61
|
+
return (graph.byKind.feature.get(id) ??
|
|
62
|
+
graph.byKind.flow.get(id) ??
|
|
63
|
+
graph.byKind.loop.get(id) ??
|
|
64
|
+
graph.byKind.junction.get(id) ??
|
|
65
|
+
graph.byKind.scenario.get(id) ??
|
|
66
|
+
graph.byKind.debt.get(id));
|
|
67
|
+
}
|
|
68
|
+
export function allNodes(graph) {
|
|
69
|
+
return [
|
|
70
|
+
...graph.byKind.feature.values(),
|
|
71
|
+
...graph.byKind.flow.values(),
|
|
72
|
+
...graph.byKind.loop.values(),
|
|
73
|
+
...graph.byKind.junction.values(),
|
|
74
|
+
...graph.byKind.scenario.values(),
|
|
75
|
+
...graph.byKind.debt.values(),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=model-graph.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export declare function buildMcpServer(targetDir: string): McpServer;
|
|
3
|
+
/** Starts the server on stdio (the shape an MCP client launches: `loopgraph mcp <dir>`). */
|
|
4
|
+
export declare function startMcpServer(targetDir: string): Promise<void>;
|
|
5
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { runInspect } from "../cli/commands/inspect.js";
|
|
5
|
+
import { runQuery } from "../query/run-query.js";
|
|
6
|
+
/**
|
|
7
|
+
* The loopgraph stdio MCP server (Proposal 006 B4 / 001 §0 §8): a few-entry
|
|
8
|
+
* query surface that hands an Agent task-relevant model slices, so it doesn't
|
|
9
|
+
* have to read the whole spec. Every tool follows the 001 §4.2 side-channel
|
|
10
|
+
* shape — the response is a compact SUMMARY plus the path to a full workspace
|
|
11
|
+
* file — keeping tool responses small while the detail stays retrievable.
|
|
12
|
+
*
|
|
13
|
+
* All tools are read-only model queries (no repoRoot, no code scan, no LLM).
|
|
14
|
+
* `targetDir` (the dir holding `model/`) is fixed at server start.
|
|
15
|
+
*/
|
|
16
|
+
function toolResult(text, isError = false) {
|
|
17
|
+
return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
|
|
18
|
+
}
|
|
19
|
+
export function buildMcpServer(targetDir) {
|
|
20
|
+
const server = new McpServer({ name: "loopgraph", version: "0.1.0" });
|
|
21
|
+
const render = (r) => toolResult(`${r.staleWarning ? `⚠ ${r.staleWarning}\n\n` : ""}${r.summary}\n\nfull detail written to: ${r.outputPath}`);
|
|
22
|
+
server.registerTool("model_inspect", {
|
|
23
|
+
description: "Slice the behavioral model around a node id (flow C1.., loop L1.., junction J-.., scenario GWT-..). Returns a summary (node skeletons + child counts + effective constraints) plus a side-channel file with the full slice. Use this first to orient on an unfamiliar part of the system.",
|
|
24
|
+
inputSchema: { id: z.string(), depth: z.number().int().min(0).optional() },
|
|
25
|
+
}, async ({ id, depth }) => {
|
|
26
|
+
try {
|
|
27
|
+
return render(await runInspect(targetDir, id, depth != null ? { depth } : {}));
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return toolResult(err instanceof Error ? err.message : String(err), true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const queryTool = (name, command, description) => server.registerTool(`model_${name}`, { description, inputSchema: { id: z.string() } }, async ({ id }) => {
|
|
34
|
+
try {
|
|
35
|
+
return render(await runQuery(targetDir, command, id));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return toolResult(err instanceof Error ? err.message : String(err), true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
queryTool("impact", "impact", "Reverse-dependency blast radius of a node: everything that references it (which flows traverse a loop, which junctions sit between it, which scenarios constrain it). Use before changing a node to see what must be re-checked.");
|
|
42
|
+
queryTool("plan", "plan", "A flow's ordered execution plan (id like C1): the traverses sequence, watchdog guards, and crossing junctions with their risk classes and endpoints. Use to understand how one end-to-end flow runs.");
|
|
43
|
+
queryTool("scenario", "scenario", "A GWT scenario's full detail (id like GWT-C1-001): given/when/then, the tests that verify it, which loops/junctions reference it, and (for invariants) the loops it applies to.");
|
|
44
|
+
queryTool("evidence", "evidence", "A node's grounding: a junction's typed evidence anchors (code/spec/issue/test) with notes, or a loop's code anchors, plus the scenarios bound to it.");
|
|
45
|
+
queryTool("matrix", "matrix", "A flow's GWT↔test coverage matrix (id like C1): every scenario reachable from the flow, the tests it's bound to, and whether it's verified — the flow's test-coverage surface at a glance.");
|
|
46
|
+
return server;
|
|
47
|
+
}
|
|
48
|
+
/** Starts the server on stdio (the shape an MCP client launches: `loopgraph mcp <dir>`). */
|
|
49
|
+
export async function startMcpServer(targetDir) {
|
|
50
|
+
const server = buildMcpServer(targetDir);
|
|
51
|
+
await server.connect(new StdioServerTransport());
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
2
|
+
/**
|
|
3
|
+
* `--diff` incremental support (Proposal 006 B2 / 001 §5 §6): resolve what a PR
|
|
4
|
+
* changed so a check re-runs only over the diff closure instead of the whole
|
|
5
|
+
* repo, and map the changed files back to the model nodes they touch.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Repo-relative paths changed between `merge-base(baseRef, HEAD)` and the
|
|
9
|
+
* working tree (committed + uncommitted), via git. Uses the merge-base (not a
|
|
10
|
+
* raw `baseRef..HEAD`) so a stale base branch doesn't inflate the diff with
|
|
11
|
+
* commits already on HEAD. Returns undefined when the ref/repo is unusable —
|
|
12
|
+
* the caller falls back to a full scan rather than silently checking nothing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function changedFiles(gitRoot: string, baseRef: string): Promise<string[] | undefined>;
|
|
15
|
+
/** The git top-level of `dir`, or undefined if `dir` isn't inside a git checkout. */
|
|
16
|
+
export declare function gitRootOf(dir: string): Promise<string | undefined>;
|
|
17
|
+
export interface AffectedNode {
|
|
18
|
+
nodeId: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
/** the anchor whose file is in the changed set */
|
|
21
|
+
anchor: string;
|
|
22
|
+
file: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Model nodes whose anchor points at one of `changedFiles` — the diff's impact
|
|
26
|
+
* surface on the model. Pure. A loop/junction/scenario is "affected" when a
|
|
27
|
+
* file it anchors to (Loop.anchors, Junction.evidence[].anchor,
|
|
28
|
+
* Scenario.verified_by[]) changed, so a reviewer sees which modeled behaviors a
|
|
29
|
+
* diff might have moved out from under.
|
|
30
|
+
*/
|
|
31
|
+
export declare function affectedNodes(graph: ModelGraph, changed: Iterable<string>): AffectedNode[];
|
|
32
|
+
/**
|
|
33
|
+
* Debt ids present in the model at `baseRef`, for the baseline-only-decreases
|
|
34
|
+
* check's "before" snapshot. `modelRelDir` is the model directory relative to
|
|
35
|
+
* `gitRoot`. Uses a SINGLE `git grep` over the base tree (not one `git show`
|
|
36
|
+
* per model file) so it stays fast on a large model.
|
|
37
|
+
*
|
|
38
|
+
* Returns undefined only when the base snapshot is genuinely unreadable (bad
|
|
39
|
+
* ref / not a git repo); an empty set (no debt at base) is a real result, not a
|
|
40
|
+
* skip. Note: the id scrape is a text match — only DebtEntry nodes can carry a
|
|
41
|
+
* `DEBT-` id (the schema's id regexes forbid it elsewhere), and a stray match
|
|
42
|
+
* in a comment/string could at worst add a spurious "before" id, which only
|
|
43
|
+
* *suppresses* a growth violation, never invents one (checkBaselineOnlyDecreases
|
|
44
|
+
* flags after-not-before).
|
|
45
|
+
*/
|
|
46
|
+
export declare function debtIdsAtRef(gitRoot: string, modelRelDir: string, baseRef: string): Promise<Set<string> | undefined>;
|
|
47
|
+
//# sourceMappingURL=diff.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { allNodes } from "../loader/model-graph.js";
|
|
4
|
+
import { anchorFilePath } from "../validate/anchor.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
/**
|
|
7
|
+
* `--diff` incremental support (Proposal 006 B2 / 001 §5 §6): resolve what a PR
|
|
8
|
+
* changed so a check re-runs only over the diff closure instead of the whole
|
|
9
|
+
* repo, and map the changed files back to the model nodes they touch.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Repo-relative paths changed between `merge-base(baseRef, HEAD)` and the
|
|
13
|
+
* working tree (committed + uncommitted), via git. Uses the merge-base (not a
|
|
14
|
+
* raw `baseRef..HEAD`) so a stale base branch doesn't inflate the diff with
|
|
15
|
+
* commits already on HEAD. Returns undefined when the ref/repo is unusable —
|
|
16
|
+
* the caller falls back to a full scan rather than silently checking nothing.
|
|
17
|
+
*/
|
|
18
|
+
export async function changedFiles(gitRoot, baseRef) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout: mb } = await execFileAsync("git", ["merge-base", baseRef, "HEAD"], {
|
|
21
|
+
cwd: gitRoot,
|
|
22
|
+
});
|
|
23
|
+
const base = mb.trim();
|
|
24
|
+
// `git diff --name-only <base>` compares base..working-tree (includes
|
|
25
|
+
// uncommitted edits), which is what a pre-push / local check wants.
|
|
26
|
+
const { stdout } = await execFileAsync("git", ["diff", "--name-only", base], {
|
|
27
|
+
cwd: gitRoot,
|
|
28
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
29
|
+
});
|
|
30
|
+
return stdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => l.trim().replace(/^"(.*)"$/, "$1"))
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined; // bad ref / not a git repo / no merge-base
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The git top-level of `dir`, or undefined if `dir` isn't inside a git checkout. */
|
|
40
|
+
export async function gitRootOf(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd: dir });
|
|
43
|
+
return stdout.trim();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Model nodes whose anchor points at one of `changedFiles` — the diff's impact
|
|
51
|
+
* surface on the model. Pure. A loop/junction/scenario is "affected" when a
|
|
52
|
+
* file it anchors to (Loop.anchors, Junction.evidence[].anchor,
|
|
53
|
+
* Scenario.verified_by[]) changed, so a reviewer sees which modeled behaviors a
|
|
54
|
+
* diff might have moved out from under.
|
|
55
|
+
*/
|
|
56
|
+
export function affectedNodes(graph, changed) {
|
|
57
|
+
// Normalize both sides to forward slashes: git emits POSIX paths and anchors
|
|
58
|
+
// are authored with `/`, but normalizing guards any backslash that slips in.
|
|
59
|
+
const posix = (p) => p.replace(/\\/g, "/");
|
|
60
|
+
const changedSet = new Set([...changed].map(posix));
|
|
61
|
+
const out = [];
|
|
62
|
+
const consider = (nodeId, kind, anchor) => {
|
|
63
|
+
const file = anchorFilePath(anchor);
|
|
64
|
+
if (file && changedSet.has(posix(file)))
|
|
65
|
+
out.push({ nodeId, kind, anchor, file });
|
|
66
|
+
};
|
|
67
|
+
for (const node of allNodes(graph)) {
|
|
68
|
+
if (node.kind === "loop") {
|
|
69
|
+
for (const a of node.anchors)
|
|
70
|
+
consider(node.id, "loop", a);
|
|
71
|
+
}
|
|
72
|
+
else if (node.kind === "junction") {
|
|
73
|
+
for (const e of node.evidence)
|
|
74
|
+
consider(node.id, "junction", e.anchor);
|
|
75
|
+
}
|
|
76
|
+
else if (node.kind === "scenario") {
|
|
77
|
+
for (const a of node.verified_by)
|
|
78
|
+
consider(node.id, "scenario", a);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Debt ids present in the model at `baseRef`, for the baseline-only-decreases
|
|
85
|
+
* check's "before" snapshot. `modelRelDir` is the model directory relative to
|
|
86
|
+
* `gitRoot`. Uses a SINGLE `git grep` over the base tree (not one `git show`
|
|
87
|
+
* per model file) so it stays fast on a large model.
|
|
88
|
+
*
|
|
89
|
+
* Returns undefined only when the base snapshot is genuinely unreadable (bad
|
|
90
|
+
* ref / not a git repo); an empty set (no debt at base) is a real result, not a
|
|
91
|
+
* skip. Note: the id scrape is a text match — only DebtEntry nodes can carry a
|
|
92
|
+
* `DEBT-` id (the schema's id regexes forbid it elsewhere), and a stray match
|
|
93
|
+
* in a comment/string could at worst add a spurious "before" id, which only
|
|
94
|
+
* *suppresses* a growth violation, never invents one (checkBaselineOnlyDecreases
|
|
95
|
+
* flags after-not-before).
|
|
96
|
+
*/
|
|
97
|
+
export async function debtIdsAtRef(gitRoot, modelRelDir, baseRef) {
|
|
98
|
+
let base;
|
|
99
|
+
try {
|
|
100
|
+
base = (await execFileAsync("git", ["merge-base", baseRef, "HEAD"], { cwd: gitRoot })).stdout.trim();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined; // bad ref / not a git repo
|
|
104
|
+
}
|
|
105
|
+
let stdout;
|
|
106
|
+
try {
|
|
107
|
+
// POSIX ERE (git grep -E) — `[[:space:]]`, not `\s` (that's PCRE). A pathspec
|
|
108
|
+
// is only appended when non-empty (modelRelDir is always `.../model` in
|
|
109
|
+
// practice, but an empty/`.` pathspec after `--` would be ambiguous).
|
|
110
|
+
const pattern = 'id:[[:space:]]*"?DEBT-[A-Za-z0-9-]+';
|
|
111
|
+
const args = modelRelDir && modelRelDir !== "."
|
|
112
|
+
? ["grep", "-hE", pattern, base, "--", modelRelDir]
|
|
113
|
+
: ["grep", "-hE", pattern, base];
|
|
114
|
+
({ stdout } = await execFileAsync("git", args, { cwd: gitRoot, maxBuffer: 32 * 1024 * 1024 }));
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// git grep exits 1 when there are simply no matches — that's an empty debt
|
|
118
|
+
// baseline (a real result), not a failure.
|
|
119
|
+
if (err.code === 1)
|
|
120
|
+
return new Set();
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const ids = new Set();
|
|
124
|
+
for (const m of stdout.matchAll(/id:\s*"?(DEBT-[A-Za-z0-9-]+)"?/g)) {
|
|
125
|
+
if (m[1])
|
|
126
|
+
ids.add(m[1]);
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
2
|
+
import type { Scenario } from "../schema/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* `owner_match` matching semantics (Decision record 004, 技术点 2):
|
|
5
|
+
* `Loop.owner` is free text, not a clean path (e.g. an owner might read
|
|
6
|
+
* `"canonical writer = packages/core service; ui layer read-only"` — the
|
|
7
|
+
* package path is not a prefix). A pattern with no `*` is a
|
|
8
|
+
* substring-containment check anywhere in `owner`. A pattern with `*`
|
|
9
|
+
* treats it as "any-length any-characters" (like a simplified `.*`),
|
|
10
|
+
* useful for asserting two fragments appear in a given order — e.g.
|
|
11
|
+
* `"packages/core*apps/worker"`. This is deliberately NOT full POSIX glob
|
|
12
|
+
* (no `**`, no character classes) — `owner` is a short free-text field,
|
|
13
|
+
* not a file path, and doesn't need that vocabulary. Case-sensitive,
|
|
14
|
+
* matching `owner`'s own casing.
|
|
15
|
+
*
|
|
16
|
+
* The `*`-pattern match is deliberately UNANCHORED (no `^`/`$`), for the
|
|
17
|
+
* same reason the no-`*` case is substring-anywhere and not a full-string
|
|
18
|
+
* equality check: real `owner` values carry surrounding decoration, so
|
|
19
|
+
* "these two fragments appear in this order" needs to tolerate a
|
|
20
|
+
* prefix/suffix around the whole match, not just between the two
|
|
21
|
+
* fragments. Anchoring would make `*`-patterns behave inconsistently
|
|
22
|
+
* stricter than plain-substring patterns for no benefit — `owner` isn't
|
|
23
|
+
* adversarial input, it's short static text authored by whoever wrote
|
|
24
|
+
* the model.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ownerMatches(owner: string, pattern: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether `scenario` applies to `nodeId`, per its `applies_to` selector.
|
|
29
|
+
* Predicates combine with OR (matching either is sufficient). `nodes`
|
|
30
|
+
* works against any of the 6 node kinds by exact id; `owner_match` only
|
|
31
|
+
* ever matches Loop nodes (other kinds have no `owner` field) and never
|
|
32
|
+
* matches a dormant loop (`owner: null`).
|
|
33
|
+
*/
|
|
34
|
+
export declare function scenarioApplies(scenario: Scenario, nodeId: string, graph: ModelGraph): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Every Scenario that applies to `nodeId`, resolved at query time —
|
|
37
|
+
* never materialized back onto the node (Decision record 004: writing
|
|
38
|
+
* this back to every matching node's `scenarios` array on every new
|
|
39
|
+
* Invariant would create merge conflicts across the user's many git
|
|
40
|
+
* worktrees, for no correctness benefit at this model's node count).
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveApplicableScenarios(graph: ModelGraph, nodeId: string): Scenario[];
|
|
43
|
+
/** Every (scenario, nodeId) pair where a Scenario's `applies_to` names a node that doesn't exist in the graph. */
|
|
44
|
+
export declare function collectDanglingAppliesTo(graph: ModelGraph): {
|
|
45
|
+
scenarioId: string;
|
|
46
|
+
targetId: string;
|
|
47
|
+
}[];
|
|
48
|
+
//# sourceMappingURL=effective-constraints.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { allNodes } from "../loader/model-graph.js";
|
|
2
|
+
/**
|
|
3
|
+
* `owner_match` matching semantics (Decision record 004, 技术点 2):
|
|
4
|
+
* `Loop.owner` is free text, not a clean path (e.g. an owner might read
|
|
5
|
+
* `"canonical writer = packages/core service; ui layer read-only"` — the
|
|
6
|
+
* package path is not a prefix). A pattern with no `*` is a
|
|
7
|
+
* substring-containment check anywhere in `owner`. A pattern with `*`
|
|
8
|
+
* treats it as "any-length any-characters" (like a simplified `.*`),
|
|
9
|
+
* useful for asserting two fragments appear in a given order — e.g.
|
|
10
|
+
* `"packages/core*apps/worker"`. This is deliberately NOT full POSIX glob
|
|
11
|
+
* (no `**`, no character classes) — `owner` is a short free-text field,
|
|
12
|
+
* not a file path, and doesn't need that vocabulary. Case-sensitive,
|
|
13
|
+
* matching `owner`'s own casing.
|
|
14
|
+
*
|
|
15
|
+
* The `*`-pattern match is deliberately UNANCHORED (no `^`/`$`), for the
|
|
16
|
+
* same reason the no-`*` case is substring-anywhere and not a full-string
|
|
17
|
+
* equality check: real `owner` values carry surrounding decoration, so
|
|
18
|
+
* "these two fragments appear in this order" needs to tolerate a
|
|
19
|
+
* prefix/suffix around the whole match, not just between the two
|
|
20
|
+
* fragments. Anchoring would make `*`-patterns behave inconsistently
|
|
21
|
+
* stricter than plain-substring patterns for no benefit — `owner` isn't
|
|
22
|
+
* adversarial input, it's short static text authored by whoever wrote
|
|
23
|
+
* the model.
|
|
24
|
+
*/
|
|
25
|
+
export function ownerMatches(owner, pattern) {
|
|
26
|
+
if (!pattern.includes("*"))
|
|
27
|
+
return owner.includes(pattern);
|
|
28
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
29
|
+
return new RegExp(escaped).test(owner);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether `scenario` applies to `nodeId`, per its `applies_to` selector.
|
|
33
|
+
* Predicates combine with OR (matching either is sufficient). `nodes`
|
|
34
|
+
* works against any of the 6 node kinds by exact id; `owner_match` only
|
|
35
|
+
* ever matches Loop nodes (other kinds have no `owner` field) and never
|
|
36
|
+
* matches a dormant loop (`owner: null`).
|
|
37
|
+
*/
|
|
38
|
+
export function scenarioApplies(scenario, nodeId, graph) {
|
|
39
|
+
const appliesTo = scenario.applies_to;
|
|
40
|
+
if (!appliesTo)
|
|
41
|
+
return false;
|
|
42
|
+
if (appliesTo.nodes.includes(nodeId))
|
|
43
|
+
return true;
|
|
44
|
+
if (appliesTo.owner_match !== undefined) {
|
|
45
|
+
const loop = graph.byKind.loop.get(nodeId);
|
|
46
|
+
if (loop && loop.owner !== null && ownerMatches(loop.owner, appliesTo.owner_match)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Every Scenario that applies to `nodeId`, resolved at query time —
|
|
54
|
+
* never materialized back onto the node (Decision record 004: writing
|
|
55
|
+
* this back to every matching node's `scenarios` array on every new
|
|
56
|
+
* Invariant would create merge conflicts across the user's many git
|
|
57
|
+
* worktrees, for no correctness benefit at this model's node count).
|
|
58
|
+
*/
|
|
59
|
+
export function resolveApplicableScenarios(graph, nodeId) {
|
|
60
|
+
return [...graph.byKind.scenario.values()].filter((scenario) => scenarioApplies(scenario, nodeId, graph));
|
|
61
|
+
}
|
|
62
|
+
/** Every (scenario, nodeId) pair where a Scenario's `applies_to` names a node that doesn't exist in the graph. */
|
|
63
|
+
export function collectDanglingAppliesTo(graph) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const node of allNodes(graph)) {
|
|
66
|
+
if (node.kind !== "scenario" || !node.applies_to)
|
|
67
|
+
continue;
|
|
68
|
+
for (const targetId of node.applies_to.nodes) {
|
|
69
|
+
const exists = graph.byKind.feature.has(targetId) ||
|
|
70
|
+
graph.byKind.flow.has(targetId) ||
|
|
71
|
+
graph.byKind.loop.has(targetId) ||
|
|
72
|
+
graph.byKind.junction.has(targetId) ||
|
|
73
|
+
graph.byKind.scenario.has(targetId) ||
|
|
74
|
+
graph.byKind.debt.has(targetId);
|
|
75
|
+
if (!exists)
|
|
76
|
+
out.push({ scenarioId: node.id, targetId });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=effective-constraints.js.map
|