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,111 @@
|
|
|
1
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
2
|
+
import type { ModelNode } from "../schema/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Phase-2 query command family (Proposal 006 B4 / 001 §4.2 §8):
|
|
5
|
+
* `impact` / `plan` / `scenario` / `evidence` — focused views over the model,
|
|
6
|
+
* distinct from A5's general-purpose `inspect` slice. All are PURE functions of
|
|
7
|
+
* the loaded graph; the CLI and MCP layers add I/O (side-channel file) around
|
|
8
|
+
* them. Each returns `undefined` when the id isn't found (callers turn that
|
|
9
|
+
* into an "unknown id" error), and a small structured result the renderers in
|
|
10
|
+
* this file turn into a stdout summary + a full side-channel body.
|
|
11
|
+
*/
|
|
12
|
+
export interface Dependent {
|
|
13
|
+
id: string;
|
|
14
|
+
kind: ModelNode["kind"];
|
|
15
|
+
/** How it depends on the queried node (which reference field links them). */
|
|
16
|
+
relation: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ImpactResult {
|
|
19
|
+
nodeId: string;
|
|
20
|
+
kind: ModelNode["kind"];
|
|
21
|
+
dependents: Dependent[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Everything that references `nodeId` — the set a change to it may force you to
|
|
25
|
+
* re-check. Reverse edges only (who points AT this node), deliberately NOT the
|
|
26
|
+
* general bidirectional slice `inspect` gives: this answers "if I change X,
|
|
27
|
+
* what's the blast radius", not "what's near X".
|
|
28
|
+
*/
|
|
29
|
+
export declare function impactOf(graph: ModelGraph, nodeId: string): ImpactResult | undefined;
|
|
30
|
+
export interface PlanStep {
|
|
31
|
+
loopId: string;
|
|
32
|
+
title: string;
|
|
33
|
+
status: string;
|
|
34
|
+
/** effective-constraint scenario ids that apply to this loop (invariants). */
|
|
35
|
+
effectiveConstraints: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface PlanResult {
|
|
38
|
+
flowId: string;
|
|
39
|
+
title: string;
|
|
40
|
+
steps: PlanStep[];
|
|
41
|
+
guards: {
|
|
42
|
+
loopId: string;
|
|
43
|
+
title: string;
|
|
44
|
+
}[];
|
|
45
|
+
junctions: {
|
|
46
|
+
id: string;
|
|
47
|
+
risk_class: string;
|
|
48
|
+
between: string[];
|
|
49
|
+
title: string;
|
|
50
|
+
}[];
|
|
51
|
+
subFlows: string[];
|
|
52
|
+
}
|
|
53
|
+
/** The ordered "how this flow runs" plan: traverses sequence, guards, crossing junctions. */
|
|
54
|
+
export declare function planOf(graph: ModelGraph, flowId: string): PlanResult | undefined;
|
|
55
|
+
export interface ScenarioResult {
|
|
56
|
+
scenario: import("../schema/index.js").Scenario;
|
|
57
|
+
/** loops/junctions that list this scenario in their `scenarios` array. */
|
|
58
|
+
referencedBy: {
|
|
59
|
+
id: string;
|
|
60
|
+
kind: "loop" | "junction";
|
|
61
|
+
}[];
|
|
62
|
+
/** loops this scenario applies to via applies_to (resolved at query time). */
|
|
63
|
+
appliesToLoops: string[];
|
|
64
|
+
}
|
|
65
|
+
export declare function scenarioDetail(graph: ModelGraph, scenarioId: string): ScenarioResult | undefined;
|
|
66
|
+
export interface EvidenceResult {
|
|
67
|
+
nodeId: string;
|
|
68
|
+
kind: ModelNode["kind"];
|
|
69
|
+
/** Junction evidence entries (kind/anchor/note), if the node is a junction. */
|
|
70
|
+
evidence: import("../schema/index.js").Evidence[];
|
|
71
|
+
/** Anchor strings, if the node is a loop. */
|
|
72
|
+
anchors: string[];
|
|
73
|
+
/** scenarios bound to this node (its own `scenarios` + effective constraints for loops). */
|
|
74
|
+
scenarios: string[];
|
|
75
|
+
}
|
|
76
|
+
export declare function evidenceOf(graph: ModelGraph, nodeId: string): EvidenceResult | undefined;
|
|
77
|
+
export interface MatrixRow {
|
|
78
|
+
scenarioId: string;
|
|
79
|
+
level: string;
|
|
80
|
+
/** Test anchors this GWT is bound to (Scenario.verified_by). Empty = unverified. */
|
|
81
|
+
verifiedBy: string[];
|
|
82
|
+
/** in-flow nodes (junction/loop) that reference this scenario, or "applies_to" for an invariant. */
|
|
83
|
+
referencedBy: string[];
|
|
84
|
+
verified: boolean;
|
|
85
|
+
}
|
|
86
|
+
export interface TestMatrixResult {
|
|
87
|
+
flowId: string;
|
|
88
|
+
title: string;
|
|
89
|
+
rows: MatrixRow[];
|
|
90
|
+
/**
|
|
91
|
+
* `unknownScenario` counts rows whose scenario id resolved to no Scenario
|
|
92
|
+
* node — always 0 on a T0-passing model (referential integrity catches
|
|
93
|
+
* dangling scenario refs), surfaced anyway so a broken model isn't silent.
|
|
94
|
+
*/
|
|
95
|
+
summary: {
|
|
96
|
+
total: number;
|
|
97
|
+
verified: number;
|
|
98
|
+
unverified: number;
|
|
99
|
+
unknownScenario: number;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The GWT↔test binding matrix for a flow (Proposal 006 B5 / 001 §12 "GWT 独立于
|
|
104
|
+
* 测试代码长期存在"): every scenario reachable from the flow — via its crossing
|
|
105
|
+
* junctions, its traversed/guarding loops, and each loop's effective
|
|
106
|
+
* constraints — with the tests it's bound to and whether it's verified. Makes
|
|
107
|
+
* the flow's test-coverage surface legible at a glance (which GWT are grounded
|
|
108
|
+
* in a real test, which are deliberately unverified).
|
|
109
|
+
*/
|
|
110
|
+
export declare function testMatrix(graph: ModelGraph, flowId: string): TestMatrixResult | undefined;
|
|
111
|
+
//# sourceMappingURL=queries.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { getNode } from "../loader/model-graph.js";
|
|
2
|
+
import { resolveApplicableScenarios, scenarioApplies } from "./effective-constraints.js";
|
|
3
|
+
/**
|
|
4
|
+
* Everything that references `nodeId` — the set a change to it may force you to
|
|
5
|
+
* re-check. Reverse edges only (who points AT this node), deliberately NOT the
|
|
6
|
+
* general bidirectional slice `inspect` gives: this answers "if I change X,
|
|
7
|
+
* what's the blast radius", not "what's near X".
|
|
8
|
+
*/
|
|
9
|
+
export function impactOf(graph, nodeId) {
|
|
10
|
+
const node = getNode(graph, nodeId);
|
|
11
|
+
if (!node)
|
|
12
|
+
return undefined;
|
|
13
|
+
const dependents = [];
|
|
14
|
+
const seen = new Set(); // dedup by id: a node is one dependent even if linked two ways
|
|
15
|
+
const add = (id, kind, relation) => {
|
|
16
|
+
if (seen.has(id))
|
|
17
|
+
return;
|
|
18
|
+
seen.add(id);
|
|
19
|
+
dependents.push({ id, kind, relation });
|
|
20
|
+
};
|
|
21
|
+
for (const flow of graph.byKind.flow.values()) {
|
|
22
|
+
if (flow.traverses.includes(nodeId))
|
|
23
|
+
add(flow.id, "flow", "traverses");
|
|
24
|
+
if (flow.guarded_by.includes(nodeId))
|
|
25
|
+
add(flow.id, "flow", "guarded_by");
|
|
26
|
+
if (flow.crosses.includes(nodeId))
|
|
27
|
+
add(flow.id, "flow", "crosses");
|
|
28
|
+
if (flow.references.includes(nodeId))
|
|
29
|
+
add(flow.id, "flow", "references");
|
|
30
|
+
}
|
|
31
|
+
for (const feature of graph.byKind.feature.values()) {
|
|
32
|
+
if (feature.contains.includes(nodeId))
|
|
33
|
+
add(feature.id, "feature", "contains");
|
|
34
|
+
}
|
|
35
|
+
for (const loop of graph.byKind.loop.values()) {
|
|
36
|
+
if (loop.parent === nodeId)
|
|
37
|
+
add(loop.id, "loop", "parent");
|
|
38
|
+
if (loop.scenarios.includes(nodeId))
|
|
39
|
+
add(loop.id, "loop", "scenarios");
|
|
40
|
+
}
|
|
41
|
+
for (const junction of graph.byKind.junction.values()) {
|
|
42
|
+
if (junction.between.includes(nodeId))
|
|
43
|
+
add(junction.id, "junction", "between");
|
|
44
|
+
if (junction.scenarios.includes(nodeId))
|
|
45
|
+
add(junction.id, "junction", "scenarios");
|
|
46
|
+
}
|
|
47
|
+
// A loop is also "impacted" by any invariant scenario that applies to it via
|
|
48
|
+
// applies_to — and a scenario's impact includes the loops it constrains.
|
|
49
|
+
if (node.kind === "scenario") {
|
|
50
|
+
for (const loop of graph.byKind.loop.values()) {
|
|
51
|
+
if (scenarioApplies(node, loop.id, graph))
|
|
52
|
+
add(loop.id, "loop", "applies_to");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (node.kind === "loop") {
|
|
56
|
+
for (const s of resolveApplicableScenarios(graph, nodeId))
|
|
57
|
+
add(s.id, "scenario", "applies_to→");
|
|
58
|
+
}
|
|
59
|
+
// stable order: kind then id
|
|
60
|
+
dependents.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
|
61
|
+
return { nodeId, kind: node.kind, dependents };
|
|
62
|
+
}
|
|
63
|
+
/** The ordered "how this flow runs" plan: traverses sequence, guards, crossing junctions. */
|
|
64
|
+
export function planOf(graph, flowId) {
|
|
65
|
+
const flow = graph.byKind.flow.get(flowId);
|
|
66
|
+
if (!flow)
|
|
67
|
+
return undefined;
|
|
68
|
+
const steps = flow.traverses.map((loopId) => {
|
|
69
|
+
const loop = graph.byKind.loop.get(loopId);
|
|
70
|
+
return {
|
|
71
|
+
loopId,
|
|
72
|
+
title: loop?.title ?? loopId,
|
|
73
|
+
status: loop?.status ?? "unknown",
|
|
74
|
+
effectiveConstraints: resolveApplicableScenarios(graph, loopId).map((s) => s.id),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
const guards = flow.guarded_by.map((loopId) => ({
|
|
78
|
+
loopId,
|
|
79
|
+
title: graph.byKind.loop.get(loopId)?.title ?? loopId,
|
|
80
|
+
}));
|
|
81
|
+
const junctions = flow.crosses
|
|
82
|
+
.map((id) => graph.byKind.junction.get(id))
|
|
83
|
+
.filter((j) => j !== undefined)
|
|
84
|
+
.map((j) => ({
|
|
85
|
+
id: j.id,
|
|
86
|
+
risk_class: j.risk_class,
|
|
87
|
+
between: j.between,
|
|
88
|
+
title: j.title ?? "",
|
|
89
|
+
}));
|
|
90
|
+
return { flowId, title: flow.title, steps, guards, junctions, subFlows: flow.references };
|
|
91
|
+
}
|
|
92
|
+
export function scenarioDetail(graph, scenarioId) {
|
|
93
|
+
const scenario = graph.byKind.scenario.get(scenarioId);
|
|
94
|
+
if (!scenario)
|
|
95
|
+
return undefined;
|
|
96
|
+
const referencedBy = [];
|
|
97
|
+
for (const loop of graph.byKind.loop.values())
|
|
98
|
+
if (loop.scenarios.includes(scenarioId))
|
|
99
|
+
referencedBy.push({ id: loop.id, kind: "loop" });
|
|
100
|
+
for (const junction of graph.byKind.junction.values())
|
|
101
|
+
if (junction.scenarios.includes(scenarioId))
|
|
102
|
+
referencedBy.push({ id: junction.id, kind: "junction" });
|
|
103
|
+
const appliesToLoops = [...graph.byKind.loop.values()]
|
|
104
|
+
.filter((l) => scenarioApplies(scenario, l.id, graph))
|
|
105
|
+
.map((l) => l.id);
|
|
106
|
+
return { scenario, referencedBy, appliesToLoops };
|
|
107
|
+
}
|
|
108
|
+
export function evidenceOf(graph, nodeId) {
|
|
109
|
+
const node = getNode(graph, nodeId);
|
|
110
|
+
if (!node)
|
|
111
|
+
return undefined;
|
|
112
|
+
const evidence = node.kind === "junction" ? node.evidence : [];
|
|
113
|
+
const anchors = node.kind === "loop" ? node.anchors : [];
|
|
114
|
+
const own = node.kind === "loop" || node.kind === "junction" ? node.scenarios : [];
|
|
115
|
+
const eff = node.kind === "loop" ? resolveApplicableScenarios(graph, nodeId).map((s) => s.id) : [];
|
|
116
|
+
const scenarios = [...new Set([...own, ...eff])];
|
|
117
|
+
return { nodeId, kind: node.kind, evidence, anchors, scenarios };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The GWT↔test binding matrix for a flow (Proposal 006 B5 / 001 §12 "GWT 独立于
|
|
121
|
+
* 测试代码长期存在"): every scenario reachable from the flow — via its crossing
|
|
122
|
+
* junctions, its traversed/guarding loops, and each loop's effective
|
|
123
|
+
* constraints — with the tests it's bound to and whether it's verified. Makes
|
|
124
|
+
* the flow's test-coverage surface legible at a glance (which GWT are grounded
|
|
125
|
+
* in a real test, which are deliberately unverified).
|
|
126
|
+
*/
|
|
127
|
+
export function testMatrix(graph, flowId) {
|
|
128
|
+
const flow = graph.byKind.flow.get(flowId);
|
|
129
|
+
if (!flow)
|
|
130
|
+
return undefined;
|
|
131
|
+
// scenarioId → the in-flow nodes that pull it in (deduped, ordered).
|
|
132
|
+
const refs = new Map();
|
|
133
|
+
const addRef = (scenarioId, from) => {
|
|
134
|
+
const set = refs.get(scenarioId) ?? new Set();
|
|
135
|
+
set.add(from);
|
|
136
|
+
refs.set(scenarioId, set);
|
|
137
|
+
};
|
|
138
|
+
for (const jid of flow.crosses) {
|
|
139
|
+
const junction = graph.byKind.junction.get(jid);
|
|
140
|
+
for (const s of junction?.scenarios ?? [])
|
|
141
|
+
addRef(s, jid);
|
|
142
|
+
}
|
|
143
|
+
for (const lid of [...flow.traverses, ...flow.guarded_by]) {
|
|
144
|
+
const loop = graph.byKind.loop.get(lid);
|
|
145
|
+
for (const s of loop?.scenarios ?? [])
|
|
146
|
+
addRef(s, lid);
|
|
147
|
+
for (const s of resolveApplicableScenarios(graph, lid))
|
|
148
|
+
addRef(s.id, `${lid}:applies_to`);
|
|
149
|
+
}
|
|
150
|
+
const rows = [...refs.keys()]
|
|
151
|
+
.map((scenarioId) => {
|
|
152
|
+
const scenario = graph.byKind.scenario.get(scenarioId);
|
|
153
|
+
const verifiedBy = scenario?.verified_by ?? [];
|
|
154
|
+
return {
|
|
155
|
+
scenarioId,
|
|
156
|
+
level: scenario?.level ?? "unknown",
|
|
157
|
+
verifiedBy,
|
|
158
|
+
referencedBy: [...(refs.get(scenarioId) ?? [])].sort(),
|
|
159
|
+
verified: verifiedBy.length > 0,
|
|
160
|
+
};
|
|
161
|
+
})
|
|
162
|
+
.sort((a, b) => a.scenarioId.localeCompare(b.scenarioId));
|
|
163
|
+
const verified = rows.filter((r) => r.verified).length;
|
|
164
|
+
const unknownScenario = rows.filter((r) => r.level === "unknown").length;
|
|
165
|
+
return {
|
|
166
|
+
flowId,
|
|
167
|
+
title: flow.title,
|
|
168
|
+
rows,
|
|
169
|
+
summary: { total: rows.length, verified, unverified: rows.length - verified, unknownScenario },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=queries.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Phase-2 query command runner (Proposal 006 B4): loads the model, runs one
|
|
3
|
+
* focused query, writes a staleness-stamped side-channel file, and returns a
|
|
4
|
+
* compact summary + the file path — the 001 §4.2 shape shared by CLI and MCP.
|
|
5
|
+
* `inspect` has its own runner (A5); this covers impact/plan/scenario/evidence.
|
|
6
|
+
*/
|
|
7
|
+
export type QueryCommand = "impact" | "plan" | "scenario" | "evidence" | "matrix";
|
|
8
|
+
export interface QueryResult {
|
|
9
|
+
command: QueryCommand;
|
|
10
|
+
id: string;
|
|
11
|
+
outputPath: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
staleWarning?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function runQuery(targetDir: string, command: QueryCommand, id: string): Promise<QueryResult>;
|
|
16
|
+
//# sourceMappingURL=run-query.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadModel } from "../loader/load-model.js";
|
|
3
|
+
import { evidenceOf, impactOf, planOf, scenarioDetail, testMatrix, } from "./queries.js";
|
|
4
|
+
import { writeSideChannel } from "./side-channel.js";
|
|
5
|
+
function renderImpact(r) {
|
|
6
|
+
const byRelation = {};
|
|
7
|
+
for (const d of r.dependents) {
|
|
8
|
+
const list = byRelation[d.relation] ?? [];
|
|
9
|
+
list.push(`${d.id}(${d.kind})`);
|
|
10
|
+
byRelation[d.relation] = list;
|
|
11
|
+
}
|
|
12
|
+
const summary = [
|
|
13
|
+
`impact ${r.nodeId} (${r.kind}) — ${r.dependents.length} dependent(s):`,
|
|
14
|
+
...Object.entries(byRelation).map(([rel, ids]) => ` via ${rel}: ${ids.join(", ")}`),
|
|
15
|
+
].join("\n");
|
|
16
|
+
const body = [
|
|
17
|
+
`# impact ${r.nodeId} (${r.kind})`,
|
|
18
|
+
"",
|
|
19
|
+
`${r.dependents.length} node(s) reference it — changing ${r.nodeId} may force re-checking these:`,
|
|
20
|
+
"",
|
|
21
|
+
...r.dependents.map((d) => `- ${d.id} (${d.kind}) — ${d.relation}`),
|
|
22
|
+
].join("\n");
|
|
23
|
+
return { summary, body };
|
|
24
|
+
}
|
|
25
|
+
function renderPlan(r) {
|
|
26
|
+
const seq = r.steps.map((s) => s.loopId).join(" → ") || "(no traverses)";
|
|
27
|
+
const summary = [
|
|
28
|
+
`plan ${r.flowId} — ${r.title}`,
|
|
29
|
+
` sequence: ${seq}`,
|
|
30
|
+
` guards: ${r.guards.map((g) => g.loopId).join(", ") || "(none)"}`,
|
|
31
|
+
` junctions: ${r.junctions.map((j) => `${j.id}[${j.risk_class}]`).join(", ") || "(none)"}`,
|
|
32
|
+
r.subFlows.length ? ` sub-flows: ${r.subFlows.join(", ")}` : "",
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join("\n");
|
|
36
|
+
const body = [
|
|
37
|
+
`# plan ${r.flowId} — ${r.title}`,
|
|
38
|
+
"",
|
|
39
|
+
"## ordered steps",
|
|
40
|
+
"",
|
|
41
|
+
...r.steps.map((s, i) => {
|
|
42
|
+
const head = `${i + 1}. **${s.loopId}** ${s.title} [${s.status}]`;
|
|
43
|
+
return s.effectiveConstraints.length
|
|
44
|
+
? `${head}\n - invariants (applies_to): ${s.effectiveConstraints.join(", ")}`
|
|
45
|
+
: head;
|
|
46
|
+
}),
|
|
47
|
+
"",
|
|
48
|
+
"## guards (watchdogs)",
|
|
49
|
+
"",
|
|
50
|
+
...(r.guards.length ? r.guards.map((g) => `- ${g.loopId} ${g.title}`) : ["(none)"]),
|
|
51
|
+
"",
|
|
52
|
+
"## crossing junctions",
|
|
53
|
+
"",
|
|
54
|
+
...(r.junctions.length
|
|
55
|
+
? r.junctions.map((j) => `- ${j.id} [${j.risk_class}] ${j.between.join("→")}${j.title ? ` — ${j.title}` : ""}`)
|
|
56
|
+
: ["(none)"]),
|
|
57
|
+
r.subFlows.length ? `\n## sub-flows\n\n${r.subFlows.map((f) => `- ${f}`).join("\n")}` : "",
|
|
58
|
+
].join("\n");
|
|
59
|
+
return { summary, body };
|
|
60
|
+
}
|
|
61
|
+
function renderScenario(r) {
|
|
62
|
+
const s = r.scenario;
|
|
63
|
+
const refs = r.referencedBy.map((x) => `${x.id}(${x.kind})`).join(", ") || "(none)";
|
|
64
|
+
const summary = [
|
|
65
|
+
`scenario ${s.id} [${s.level}] — verified_by ${s.verified_by.length} test(s)`,
|
|
66
|
+
` referenced by: ${refs}`,
|
|
67
|
+
r.appliesToLoops.length ? ` applies to loops: ${r.appliesToLoops.join(", ")}` : "",
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join("\n");
|
|
71
|
+
const body = [
|
|
72
|
+
`# scenario ${s.id} [${s.level}]`,
|
|
73
|
+
"",
|
|
74
|
+
`- **given** ${s.given}`,
|
|
75
|
+
`- **when** ${s.when}`,
|
|
76
|
+
`- **then** ${s.then}`,
|
|
77
|
+
`- **verified_by**: ${s.verified_by.length ? s.verified_by.join(", ") : "(none — unverified)"}`,
|
|
78
|
+
s.applies_to
|
|
79
|
+
? `- **applies_to**: nodes=[${s.applies_to.nodes.join(", ")}]${s.applies_to.owner_match ? ` owner_match="${s.applies_to.owner_match}"` : ""}`
|
|
80
|
+
: "",
|
|
81
|
+
"",
|
|
82
|
+
`## referenced by\n\n${r.referencedBy.length ? r.referencedBy.map((x) => `- ${x.id} (${x.kind})`).join("\n") : "(none)"}`,
|
|
83
|
+
r.appliesToLoops.length
|
|
84
|
+
? `\n## applies to loops (resolved)\n\n${r.appliesToLoops.map((l) => `- ${l}`).join("\n")}`
|
|
85
|
+
: "",
|
|
86
|
+
]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join("\n");
|
|
89
|
+
return { summary, body };
|
|
90
|
+
}
|
|
91
|
+
function renderEvidence(r) {
|
|
92
|
+
const summary = [
|
|
93
|
+
`evidence ${r.nodeId} (${r.kind}) — ${r.evidence.length} evidence, ${r.anchors.length} anchor(s), ${r.scenarios.length} scenario(s)`,
|
|
94
|
+
...r.evidence.map((e) => ` [${e.kind}] ${e.anchor}`),
|
|
95
|
+
...r.anchors.map((a) => ` anchor: ${a}`),
|
|
96
|
+
].join("\n");
|
|
97
|
+
const body = [
|
|
98
|
+
`# evidence ${r.nodeId} (${r.kind})`,
|
|
99
|
+
"",
|
|
100
|
+
r.evidence.length ? "## evidence" : "",
|
|
101
|
+
...r.evidence.map((e) => `- [${e.kind}] \`${e.anchor}\`${e.note ? ` — ${e.note}` : ""}`),
|
|
102
|
+
r.anchors.length ? "\n## anchors" : "",
|
|
103
|
+
...r.anchors.map((a) => `- \`${a}\``),
|
|
104
|
+
r.scenarios.length
|
|
105
|
+
? `\n## bound scenarios\n\n${r.scenarios.map((x) => `- ${x}`).join("\n")}`
|
|
106
|
+
: "",
|
|
107
|
+
]
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join("\n");
|
|
110
|
+
return { summary, body };
|
|
111
|
+
}
|
|
112
|
+
function renderMatrix(r) {
|
|
113
|
+
const unknownNote = r.summary.unknownScenario
|
|
114
|
+
? ` (⚠ ${r.summary.unknownScenario} dangling scenario ref)`
|
|
115
|
+
: "";
|
|
116
|
+
const summary = [
|
|
117
|
+
`matrix ${r.flowId} — ${r.title}`,
|
|
118
|
+
` ${r.summary.total} GWT: ${r.summary.verified} verified / ${r.summary.unverified} unverified${unknownNote}`,
|
|
119
|
+
].join("\n");
|
|
120
|
+
const body = [
|
|
121
|
+
`# test matrix ${r.flowId} — ${r.title}`,
|
|
122
|
+
"",
|
|
123
|
+
`${r.summary.total} scenarios · ${r.summary.verified} verified · ${r.summary.unverified} unverified${unknownNote}`,
|
|
124
|
+
"",
|
|
125
|
+
"| GWT | level | verified | verified_by | referenced by |",
|
|
126
|
+
"|---|---|---|---|---|",
|
|
127
|
+
...r.rows.map((row) => `| ${row.scenarioId} | ${row.level} | ${row.verified ? "✅" : "—"} | ${
|
|
128
|
+
// "; "-separated so the cell stays readable as plain text (not just rendered)
|
|
129
|
+
row.verifiedBy.length ? row.verifiedBy.map((v) => `\`${v}\``).join("; ") : "(none)"} | ${row.referencedBy.join(", ")} |`),
|
|
130
|
+
"",
|
|
131
|
+
].join("\n");
|
|
132
|
+
return { summary, body };
|
|
133
|
+
}
|
|
134
|
+
function render(command, graph, id) {
|
|
135
|
+
switch (command) {
|
|
136
|
+
case "impact": {
|
|
137
|
+
const r = impactOf(graph, id);
|
|
138
|
+
return r && renderImpact(r);
|
|
139
|
+
}
|
|
140
|
+
case "plan": {
|
|
141
|
+
const r = planOf(graph, id);
|
|
142
|
+
return r && renderPlan(r);
|
|
143
|
+
}
|
|
144
|
+
case "scenario": {
|
|
145
|
+
const r = scenarioDetail(graph, id);
|
|
146
|
+
return r && renderScenario(r);
|
|
147
|
+
}
|
|
148
|
+
case "evidence": {
|
|
149
|
+
const r = evidenceOf(graph, id);
|
|
150
|
+
return r && renderEvidence(r);
|
|
151
|
+
}
|
|
152
|
+
case "matrix": {
|
|
153
|
+
const r = testMatrix(graph, id);
|
|
154
|
+
return r && renderMatrix(r);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export async function runQuery(targetDir, command, id) {
|
|
159
|
+
const load = await loadModel(join(targetDir, "model"));
|
|
160
|
+
if (load.parseErrors.length > 0) {
|
|
161
|
+
throw new Error(`model has ${load.parseErrors.length} parse error(s) — run "loopgraph check" first`);
|
|
162
|
+
}
|
|
163
|
+
const rendered = render(command, load.graph, id);
|
|
164
|
+
if (!rendered) {
|
|
165
|
+
const expected = command === "plan" || command === "matrix"
|
|
166
|
+
? "flow"
|
|
167
|
+
: command === "scenario"
|
|
168
|
+
? "scenario"
|
|
169
|
+
: "node";
|
|
170
|
+
throw new Error(`unknown ${expected} id "${id}" for \`${command}\``);
|
|
171
|
+
}
|
|
172
|
+
const { outputPath, staleWarning } = await writeSideChannel(targetDir, `${command}-${id}`, (banner) => `${banner}\n\n${rendered.body}\n`);
|
|
173
|
+
return {
|
|
174
|
+
command,
|
|
175
|
+
id,
|
|
176
|
+
outputPath,
|
|
177
|
+
summary: `${rendered.summary}\n (full detail: ${outputPath})`,
|
|
178
|
+
...(staleWarning ? { staleWarning } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=run-query.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SideChannelResult {
|
|
2
|
+
outputPath: string;
|
|
3
|
+
/** Set when a pre-existing file for this tag was stale vs the current model. */
|
|
4
|
+
staleWarning?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Shared side-channel writer for the query family (extracted from A5's inspect
|
|
8
|
+
* so inspect/impact/plan/scenario/evidence don't each re-implement it). Writes
|
|
9
|
+
* `<targetDir>/.loopgraph/ws/<fileTag>.md`, stamped with a staleness banner,
|
|
10
|
+
* and — before overwriting — reads any prior file and warns if it was stale
|
|
11
|
+
* (Decision 004 技术点 4 consumer). `buildBody` receives the banner to place it
|
|
12
|
+
* wherever the body wants (top, by convention).
|
|
13
|
+
*
|
|
14
|
+
* Concurrency: the side-channel file is a regenerated-on-demand ADVISORY
|
|
15
|
+
* artifact, not source data — the model YAML is never touched here, only this
|
|
16
|
+
* derived output. The read→judge→write is deliberately NOT locked: the worst
|
|
17
|
+
* case under a race is a spurious/missed staleness *warning* plus one of two
|
|
18
|
+
* near-identical regenerations winning; no model data is at risk, so a file
|
|
19
|
+
* lock is not pulled in for a local dev tool. The write itself IS atomic — a
|
|
20
|
+
* uniquely-named temp (`randomUUID`, so two concurrent writers in the SAME
|
|
21
|
+
* process, e.g. two MCP tool calls for the same id, never collide on the temp)
|
|
22
|
+
* then `rename`, so a reader never observes a half-written file.
|
|
23
|
+
*/
|
|
24
|
+
export declare function writeSideChannel(targetDir: string, fileTag: string, buildBody: (banner: string) => string): Promise<SideChannelResult>;
|
|
25
|
+
//# sourceMappingURL=side-channel.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { computeStalenessStamp, formatStalenessBanner, isStale, parseStalenessBanner, } from "../staleness.js";
|
|
5
|
+
function shortHash(h) {
|
|
6
|
+
return h.slice(0, 12);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Shared side-channel writer for the query family (extracted from A5's inspect
|
|
10
|
+
* so inspect/impact/plan/scenario/evidence don't each re-implement it). Writes
|
|
11
|
+
* `<targetDir>/.loopgraph/ws/<fileTag>.md`, stamped with a staleness banner,
|
|
12
|
+
* and — before overwriting — reads any prior file and warns if it was stale
|
|
13
|
+
* (Decision 004 技术点 4 consumer). `buildBody` receives the banner to place it
|
|
14
|
+
* wherever the body wants (top, by convention).
|
|
15
|
+
*
|
|
16
|
+
* Concurrency: the side-channel file is a regenerated-on-demand ADVISORY
|
|
17
|
+
* artifact, not source data — the model YAML is never touched here, only this
|
|
18
|
+
* derived output. The read→judge→write is deliberately NOT locked: the worst
|
|
19
|
+
* case under a race is a spurious/missed staleness *warning* plus one of two
|
|
20
|
+
* near-identical regenerations winning; no model data is at risk, so a file
|
|
21
|
+
* lock is not pulled in for a local dev tool. The write itself IS atomic — a
|
|
22
|
+
* uniquely-named temp (`randomUUID`, so two concurrent writers in the SAME
|
|
23
|
+
* process, e.g. two MCP tool calls for the same id, never collide on the temp)
|
|
24
|
+
* then `rename`, so a reader never observes a half-written file.
|
|
25
|
+
*/
|
|
26
|
+
export async function writeSideChannel(targetDir, fileTag, buildBody) {
|
|
27
|
+
const modelDir = join(targetDir, "model");
|
|
28
|
+
const currentStamp = await computeStalenessStamp(modelDir, targetDir);
|
|
29
|
+
const wsDir = join(targetDir, ".loopgraph", "ws");
|
|
30
|
+
await mkdir(wsDir, { recursive: true });
|
|
31
|
+
const outputPath = join(wsDir, `${fileTag}.md`);
|
|
32
|
+
let staleWarning;
|
|
33
|
+
const prior = await readFile(outputPath, "utf8").catch(() => undefined);
|
|
34
|
+
if (prior) {
|
|
35
|
+
const priorStamp = parseStalenessBanner(prior);
|
|
36
|
+
if (priorStamp && isStale(priorStamp, currentStamp)) {
|
|
37
|
+
staleWarning = `prior ${outputPath} was stale: model_content_hash ${shortHash(priorStamp.modelContentHash)} != current ${shortHash(currentStamp.modelContentHash)} — regenerated`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const body = buildBody(formatStalenessBanner(currentStamp));
|
|
41
|
+
const tmpPath = `${outputPath}.${randomUUID()}.tmp`;
|
|
42
|
+
await writeFile(tmpPath, body, "utf8");
|
|
43
|
+
await rename(tmpPath, outputPath);
|
|
44
|
+
return { outputPath, ...(staleWarning ? { staleWarning } : {}) };
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=side-channel.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
2
|
+
import type { ModelNode } from "../schema/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reusable model-slice join (Proposal 006 A5). Given a start node id and a
|
|
5
|
+
* traversal depth, produce the connected task-relevant sub-graph:
|
|
6
|
+
* flow→loops/junctions/sub-flows, loop→junctions/scenarios/parent/effective
|
|
7
|
+
* constraints, junction→loops/scenarios, scenario→referrers/applies_to.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately factored out of the `inspect` CLI command so the
|
|
10
|
+
* Phase 2 query family (`model impact`/`plan`/`scenario`/`evidence`) reuses
|
|
11
|
+
* one traversal + one summary/truncation module (proposal 001 §4.2), rather
|
|
12
|
+
* than each command re-deriving "what's related to X".
|
|
13
|
+
*/
|
|
14
|
+
export interface SliceNode {
|
|
15
|
+
id: string;
|
|
16
|
+
kind: ModelNode["kind"];
|
|
17
|
+
/** Shortest hop distance from the root (root = 0). */
|
|
18
|
+
depth: number;
|
|
19
|
+
node: ModelNode;
|
|
20
|
+
/**
|
|
21
|
+
* For loops only: scenario ids that apply via `applies_to` (effective
|
|
22
|
+
* constraints, Decision 004 技术点 2) — computed at query time, not
|
|
23
|
+
* hand-listed on the node. Undefined for non-loop kinds.
|
|
24
|
+
*/
|
|
25
|
+
effectiveConstraints?: string[];
|
|
26
|
+
}
|
|
27
|
+
/** A neighbor one hop past the traversal frontier, surfaced as a pointer (not expanded). */
|
|
28
|
+
export interface FrontierPointer {
|
|
29
|
+
id: string;
|
|
30
|
+
kind: ModelNode["kind"];
|
|
31
|
+
/** An in-slice node that references this pointer (so the user knows where it hangs off). */
|
|
32
|
+
fromId: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ModelSlice {
|
|
35
|
+
rootId: string;
|
|
36
|
+
rootKind: ModelNode["kind"];
|
|
37
|
+
maxDepth: number;
|
|
38
|
+
/** Reached nodes, ordered by (depth, id). Includes the root at depth 0. */
|
|
39
|
+
nodes: SliceNode[];
|
|
40
|
+
/** Neighbors beyond `maxDepth` that were not expanded — expand with `--depth`. */
|
|
41
|
+
frontierPointers: FrontierPointer[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Depth-limited breadth-first slice from `rootId`. Returns undefined if the
|
|
45
|
+
* root id isn't a node in the graph (the CLI turns that into an "unknown id"
|
|
46
|
+
* error).
|
|
47
|
+
*
|
|
48
|
+
* Standard mark-at-enqueue BFS: a node is added to `enqueued` the moment it's
|
|
49
|
+
* first discovered, so (a) each node is enqueued exactly once and recorded at
|
|
50
|
+
* its shortest distance from the root, and (b) any node reachable within
|
|
51
|
+
* `maxDepth` is marked (via its shallower parent, which the depth-ordered FIFO
|
|
52
|
+
* dequeues first) before a depth==maxDepth sibling could add it to the
|
|
53
|
+
* frontier — so `frontierPointers` and the in-slice node set never overlap by
|
|
54
|
+
* construction, no post-hoc reconciliation needed.
|
|
55
|
+
*/
|
|
56
|
+
export declare function sliceModel(graph: ModelGraph, rootId: string, maxDepth: number): ModelSlice | undefined;
|
|
57
|
+
//# sourceMappingURL=slice.d.ts.map
|