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,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,4 @@
1
+ export * from "./model-graph.js";
2
+ export * from "./find-yaml-files.js";
3
+ export * from "./load-model.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export * from "./model-graph.js";
2
+ export * from "./find-yaml-files.js";
3
+ export * from "./load-model.js";
4
+ //# sourceMappingURL=index.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
@@ -0,0 +1,7 @@
1
+ export * from "./effective-constraints.js";
2
+ export * from "./queries.js";
3
+ export * from "./run-query.js";
4
+ export * from "./side-channel.js";
5
+ export * from "./slice.js";
6
+ export * from "./summary.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,7 @@
1
+ export * from "./effective-constraints.js";
2
+ export * from "./queries.js";
3
+ export * from "./run-query.js";
4
+ export * from "./side-channel.js";
5
+ export * from "./slice.js";
6
+ export * from "./summary.js";
7
+ //# sourceMappingURL=index.js.map