loopgraph 0.2.1 → 0.3.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/README.md +2 -1
- package/dist/cli/commands/coverage.d.ts +31 -0
- package/dist/cli/commands/coverage.js +42 -0
- package/dist/cli/run.js +17 -0
- package/dist/query/coverage.d.ts +77 -0
- package/dist/query/coverage.js +84 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
以结构化行为模型(Feature / Flow / Loop / Junction / Scenario(GWT) / Evidence)为规范事实源,把目标仓库的代码、测试与可观测信号当作投影与证据:
|
|
8
8
|
|
|
9
|
-
- **确定性增量 CI(永不调 LLM)**:T0 检查(schema / 引用完整性 / 图无环 / 锚点存在性)、canonical-writer 不变量扫描(纯 AST)
|
|
9
|
+
- **确定性增量 CI(永不调 LLM)**:T0 检查(schema / 引用完整性 / 图无环 / 锚点存在性)、canonical-writer 不变量扫描(纯 AST)、实现事实对账、模型侧覆盖报告、`--diff` 增量、内容寻址共享缓存(跨 worktree)。全量亚秒级。
|
|
10
10
|
- **发现靠 LLM,防腐靠引擎**:随附 agent 提示词套件(四遍扫描:假设都是 loop → 三判据证伪 → 组合 → trace 升维),由 coding agent 离线建模;引擎负责锚点核实与漂移拦截。
|
|
11
11
|
- **MCP 查询 + file side-channel**:给 agent 提供任务相关的模型切片(impact / plan / scenario / evidence / matrix),替代全量阅读 spec。
|
|
12
12
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
npx loopgraph init # 在目标仓库生成 model/ 骨架 + .loopgraph/agent/ 指令套件
|
|
17
17
|
npx loopgraph check . --repo-root . --strict-anchors # T0 门禁(确定性,秒级)
|
|
18
18
|
npx loopgraph reconcile . --repo-root . # 实现信号 vs 模型登记对账(advisory)
|
|
19
|
+
npx loopgraph coverage . # 模型侧覆盖:多少 loop 真被锚点/场景绑住(advisory)
|
|
19
20
|
npx loopgraph snapshot . --repo-root . # nightly 全量快照 + 漂移报告(永不进门禁)
|
|
20
21
|
npx loopgraph impact <loop-id> . # 改这里会牵动什么
|
|
21
22
|
npx loopgraph mcp . # stdio MCP server(给 agent 用)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Coverage } from "../../query/coverage.js";
|
|
2
|
+
export type CoverageResult = {
|
|
3
|
+
ran: true;
|
|
4
|
+
coverage: Coverage;
|
|
5
|
+
} | {
|
|
6
|
+
ran: false;
|
|
7
|
+
skippedReason: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* `loopgraph coverage`: report how much of the model is directly bound to
|
|
11
|
+
* anchors/scenarios (see src/query/coverage.ts for why "directly").
|
|
12
|
+
*
|
|
13
|
+
* This command shares an advisory CI job with `check` — which is the command
|
|
14
|
+
* whose job it is to FAIL on a broken model — so it never exits non-zero and
|
|
15
|
+
* never refuses to report just because `check` will also complain. But "don't
|
|
16
|
+
* fail" must not become "report a number that isn't true:
|
|
17
|
+
*
|
|
18
|
+
* - Model dir missing/unreadable → `ran: false`. Reported as a skip, never as
|
|
19
|
+
* "0% coverage", which reads as a real (and alarming) measurement of a model
|
|
20
|
+
* that simply wasn't found.
|
|
21
|
+
* - EVERY file failed to parse → also `ran: false`, for the same reason. The
|
|
22
|
+
* dir existing doesn't make `0/0` a measurement. (`findYamlFiles` only throws
|
|
23
|
+
* on readdir errors; per-file YAML/schema failures land in `parseErrors` and
|
|
24
|
+
* would otherwise sail straight past the catch below.)
|
|
25
|
+
* - SOME files failed to parse → report, but carry the count through so the
|
|
26
|
+
* output can say so. Partial loading does NOT simply "depress coverage":
|
|
27
|
+
* dropping unanchored loops shrinks the denominator and can push the reported
|
|
28
|
+
* percentage UP (measured: one broken file moved 27/67=40% to 26/57=46%).
|
|
29
|
+
*/
|
|
30
|
+
export declare function runCoverage(targetDir: string): Promise<CoverageResult>;
|
|
31
|
+
//# sourceMappingURL=coverage.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadModel } from "../../loader/load-model.js";
|
|
3
|
+
import { computeCoverage } from "../../query/coverage.js";
|
|
4
|
+
/**
|
|
5
|
+
* `loopgraph coverage`: report how much of the model is directly bound to
|
|
6
|
+
* anchors/scenarios (see src/query/coverage.ts for why "directly").
|
|
7
|
+
*
|
|
8
|
+
* This command shares an advisory CI job with `check` — which is the command
|
|
9
|
+
* whose job it is to FAIL on a broken model — so it never exits non-zero and
|
|
10
|
+
* never refuses to report just because `check` will also complain. But "don't
|
|
11
|
+
* fail" must not become "report a number that isn't true:
|
|
12
|
+
*
|
|
13
|
+
* - Model dir missing/unreadable → `ran: false`. Reported as a skip, never as
|
|
14
|
+
* "0% coverage", which reads as a real (and alarming) measurement of a model
|
|
15
|
+
* that simply wasn't found.
|
|
16
|
+
* - EVERY file failed to parse → also `ran: false`, for the same reason. The
|
|
17
|
+
* dir existing doesn't make `0/0` a measurement. (`findYamlFiles` only throws
|
|
18
|
+
* on readdir errors; per-file YAML/schema failures land in `parseErrors` and
|
|
19
|
+
* would otherwise sail straight past the catch below.)
|
|
20
|
+
* - SOME files failed to parse → report, but carry the count through so the
|
|
21
|
+
* output can say so. Partial loading does NOT simply "depress coverage":
|
|
22
|
+
* dropping unanchored loops shrinks the denominator and can push the reported
|
|
23
|
+
* percentage UP (measured: one broken file moved 27/67=40% to 26/57=46%).
|
|
24
|
+
*/
|
|
25
|
+
export async function runCoverage(targetDir) {
|
|
26
|
+
let load;
|
|
27
|
+
try {
|
|
28
|
+
load = await loadModel(join(targetDir, "model"));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return { ran: false, skippedReason: err instanceof Error ? err.message : String(err) };
|
|
32
|
+
}
|
|
33
|
+
const parseErrors = load.parseErrors.length;
|
|
34
|
+
if (parseErrors > 0 && load.entries.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
ran: false,
|
|
37
|
+
skippedReason: `all ${parseErrors} model file(s) failed to parse — run "loopgraph check" for the errors`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { ran: true, coverage: computeCoverage(load.graph, parseErrors) };
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=coverage.js.map
|
package/dist/cli/run.js
CHANGED
|
@@ -2,11 +2,13 @@ import { runFacts } from "../adapters/agenthub/facts.js";
|
|
|
2
2
|
import { adapterNames, getAdapter } from "../adapters/registry.js";
|
|
3
3
|
import { loadModel } from "../loader/load-model.js";
|
|
4
4
|
import { startMcpServer } from "../mcp/server.js";
|
|
5
|
+
import { formatCoverage } from "../query/coverage.js";
|
|
5
6
|
import { runQuery } from "../query/run-query.js";
|
|
6
7
|
import { inv1ViolationsFrom } from "../validate/inv1/check.js";
|
|
7
8
|
import { reconcileFacts } from "../validate/unregistered.js";
|
|
8
9
|
import { parseFlags } from "./args.js";
|
|
9
10
|
import { runCheck } from "./commands/check.js";
|
|
11
|
+
import { runCoverage } from "./commands/coverage.js";
|
|
10
12
|
import { runImport } from "./commands/import.js";
|
|
11
13
|
import { runInit } from "./commands/init.js";
|
|
12
14
|
import { runInspect } from "./commands/inspect.js";
|
|
@@ -30,6 +32,7 @@ const USAGE = "usage: loopgraph <init|import|check> [dir] [--adapter name] [--re
|
|
|
30
32
|
" loopgraph <impact|plan|scenario|evidence|matrix> <id> [dir]\n" +
|
|
31
33
|
" loopgraph mcp [dir] # start the stdio MCP server\n" +
|
|
32
34
|
" loopgraph facts [repo] [--adapter name] # extract implementation facts (default adapter: agenthub)\n" +
|
|
35
|
+
" loopgraph coverage [dir] # model-side coverage: how much of the model is anchored\n" +
|
|
33
36
|
" loopgraph snapshot [dir] --repo-root path [--out file] [--drift prior.json] # T2 nightly scan artifact";
|
|
34
37
|
/**
|
|
35
38
|
* Reads a `--flag value` string option. Returns `{ error }` when the flag
|
|
@@ -302,6 +305,20 @@ export async function run(argv, io) {
|
|
|
302
305
|
`${coveredFiles.length} model-covered file(s)`);
|
|
303
306
|
return flags.strict === true && unregistered.length > 0 ? 1 : 0;
|
|
304
307
|
}
|
|
308
|
+
case "coverage": {
|
|
309
|
+
// Model-side coverage (advisory, always exit 0). The counterweight to
|
|
310
|
+
// `reconcile`: that command measures code→model, this one measures how
|
|
311
|
+
// much of the model is itself anchored. A report carrying only reconcile
|
|
312
|
+
// reads as "covered" on a model that only covers one flow.
|
|
313
|
+
const result = await runCoverage(targetDir);
|
|
314
|
+
if (!result.ran) {
|
|
315
|
+
io.log(`⚠ coverage skipped: ${result.skippedReason}`);
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
for (const line of formatCoverage(result.coverage))
|
|
319
|
+
io.log(line);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
305
322
|
case "snapshot": {
|
|
306
323
|
// T2 nightly full-scan artifact (Proposal 006 D2). NEVER a PR gate
|
|
307
324
|
// (001 §6): this always exits 0 — drift is reported, not failed on.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
2
|
+
/**
|
|
3
|
+
* Model-side coverage: how much of the model is actually bound to something
|
|
4
|
+
* checkable. This is the OPPOSITE direction from `reconcile`, which asks
|
|
5
|
+
* "does the code contain a route/queue/poller the model forgot to register?"
|
|
6
|
+
* — a whole-repo, code→model measure that reads near-100% on a model that
|
|
7
|
+
* covers one flow, because unregistered facts are the only thing it can see.
|
|
8
|
+
* Nothing in that number tells a reader how much of the MODEL is anchored,
|
|
9
|
+
* so a report carrying only `reconcile` invites "96% — looks covered".
|
|
10
|
+
*
|
|
11
|
+
* Every count here is DIRECT attachment (`Loop.anchors`, `Loop.scenarios`,
|
|
12
|
+
* `Junction.scenarios`). Resolving `applies_to` into the covered counts would
|
|
13
|
+
* defeat the point: one `owner_match` invariant matches every loop whose free-text
|
|
14
|
+
* `owner` contains the pattern, which silently flips large parts of the model to
|
|
15
|
+
* "covered" without any loop-specific behavior being written down. That number is
|
|
16
|
+
* still worth seeing, so it is reported on its own labelled line
|
|
17
|
+
* (`loopsOnlyViaSelector`) and never folded into `loopsWithScenarios`.
|
|
18
|
+
*/
|
|
19
|
+
export interface FlowCoverage {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
/** Loops in `traverses` (guarded_by/references deliberately excluded — see computeCoverage). */
|
|
23
|
+
loops: number;
|
|
24
|
+
/** Of those, how many carry >=1 directly-attached scenario. */
|
|
25
|
+
loopsWithScenarios: number;
|
|
26
|
+
}
|
|
27
|
+
export interface Coverage {
|
|
28
|
+
loops: number;
|
|
29
|
+
loopsWithAnchors: number;
|
|
30
|
+
loopsWithScenarios: number;
|
|
31
|
+
/**
|
|
32
|
+
* Loops with no directly-attached scenario that are nonetheless selected by
|
|
33
|
+
* some scenario's `applies_to`. Reported separately so query-time selection
|
|
34
|
+
* can't masquerade as attached coverage — deliberately NOT split by selector
|
|
35
|
+
* kind: `nodes` names a loop explicitly and `owner_match` sweeps a package,
|
|
36
|
+
* but neither is written on the loop, which is the distinction this line draws.
|
|
37
|
+
*/
|
|
38
|
+
loopsOnlyViaSelector: number;
|
|
39
|
+
/**
|
|
40
|
+
* Model files that failed to parse. Non-zero means every count here is a LOWER
|
|
41
|
+
* BOUND on a partially-loaded graph — critically, it can move the reported
|
|
42
|
+
* PERCENTAGES either way: dropping an unanchored loop shrinks the denominator
|
|
43
|
+
* and makes coverage look better (measured: one broken file took 27/67=40% to
|
|
44
|
+
* 26/57=46%). Silently reporting the higher number would make a command whose
|
|
45
|
+
* whole purpose is to stop over-reading coverage do exactly that, so this is
|
|
46
|
+
* surfaced in the output rather than left to `check` in the same job.
|
|
47
|
+
*/
|
|
48
|
+
parseErrors: number;
|
|
49
|
+
junctions: number;
|
|
50
|
+
junctionsWithScenarios: number;
|
|
51
|
+
scenarios: number;
|
|
52
|
+
/** Scenarios with a non-empty `verified_by` (the rest are unverified by construction). */
|
|
53
|
+
scenariosVerified: number;
|
|
54
|
+
/** DISTINCT `verified_by` anchor strings — the real anti-rot surface, always <= the sum of references. */
|
|
55
|
+
uniqueTestAnchors: number;
|
|
56
|
+
flows: FlowCoverage[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Per-flow coverage counts only `traverses`. `guarded_by` holds watchdog loops
|
|
60
|
+
* that cover a flow without sitting in its sequence, and `references` holds
|
|
61
|
+
* sub-flows — folding either in would let C1's coverage leak into every flow
|
|
62
|
+
* that C1 guards or references, which is precisely the "looks more covered than
|
|
63
|
+
* it is" failure this command exists to prevent.
|
|
64
|
+
*
|
|
65
|
+
* A loop traversed by several flows counts toward each of them. That is
|
|
66
|
+
* intentional: the question a flow row answers is "is THIS chain's behavior
|
|
67
|
+
* written down", and a shared loop genuinely is written down for both.
|
|
68
|
+
*/
|
|
69
|
+
export declare function computeCoverage(graph: ModelGraph, parseErrors?: number): Coverage;
|
|
70
|
+
/**
|
|
71
|
+
* Plain-text report, one fact per line, shaped to drop into the same job
|
|
72
|
+
* summary as `check`/`reconcile` output. Flows with zero covered loops are
|
|
73
|
+
* listed explicitly rather than summarized as a count — "C4 会话复活: 0/3"
|
|
74
|
+
* is the line that stops a reader assuming the whole model is anchored.
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatCoverage(coverage: Coverage): string[];
|
|
77
|
+
//# sourceMappingURL=coverage.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { scenarioApplies } from "./effective-constraints.js";
|
|
2
|
+
/**
|
|
3
|
+
* Per-flow coverage counts only `traverses`. `guarded_by` holds watchdog loops
|
|
4
|
+
* that cover a flow without sitting in its sequence, and `references` holds
|
|
5
|
+
* sub-flows — folding either in would let C1's coverage leak into every flow
|
|
6
|
+
* that C1 guards or references, which is precisely the "looks more covered than
|
|
7
|
+
* it is" failure this command exists to prevent.
|
|
8
|
+
*
|
|
9
|
+
* A loop traversed by several flows counts toward each of them. That is
|
|
10
|
+
* intentional: the question a flow row answers is "is THIS chain's behavior
|
|
11
|
+
* written down", and a shared loop genuinely is written down for both.
|
|
12
|
+
*/
|
|
13
|
+
export function computeCoverage(graph, parseErrors = 0) {
|
|
14
|
+
const loops = [...graph.byKind.loop.values()];
|
|
15
|
+
const junctions = [...graph.byKind.junction.values()];
|
|
16
|
+
const scenarios = [...graph.byKind.scenario.values()];
|
|
17
|
+
const loopHasScenario = new Map(loops.map((loop) => [loop.id, loop.scenarios.length > 0]));
|
|
18
|
+
const anchors = new Set();
|
|
19
|
+
let scenariosVerified = 0;
|
|
20
|
+
for (const scenario of scenarios) {
|
|
21
|
+
if (scenario.verified_by.length > 0)
|
|
22
|
+
scenariosVerified += 1;
|
|
23
|
+
for (const anchor of scenario.verified_by)
|
|
24
|
+
anchors.add(anchor);
|
|
25
|
+
}
|
|
26
|
+
const loopsOnlyViaSelector = loops.filter((loop) => loop.scenarios.length === 0 &&
|
|
27
|
+
scenarios.some((scenario) => scenarioApplies(scenario, loop.id, graph))).length;
|
|
28
|
+
const flows = [...graph.byKind.flow.values()].map((flow) => ({
|
|
29
|
+
id: flow.id,
|
|
30
|
+
title: flow.title,
|
|
31
|
+
loops: flow.traverses.length,
|
|
32
|
+
// An id in `traverses` that resolves to no loop node is a dangling
|
|
33
|
+
// reference — T0's job to flag, not ours. It counts toward `loops` (the
|
|
34
|
+
// flow does claim to traverse it) but never toward covered, so a broken
|
|
35
|
+
// reference can only ever depress coverage, never inflate it.
|
|
36
|
+
loopsWithScenarios: flow.traverses.filter((id) => loopHasScenario.get(id) === true).length,
|
|
37
|
+
}));
|
|
38
|
+
return {
|
|
39
|
+
loops: loops.length,
|
|
40
|
+
loopsWithAnchors: loops.filter((loop) => loop.anchors.length > 0).length,
|
|
41
|
+
loopsWithScenarios: loops.filter((loop) => loop.scenarios.length > 0).length,
|
|
42
|
+
loopsOnlyViaSelector,
|
|
43
|
+
parseErrors,
|
|
44
|
+
junctions: junctions.length,
|
|
45
|
+
junctionsWithScenarios: junctions.filter((j) => j.scenarios.length > 0).length,
|
|
46
|
+
scenarios: scenarios.length,
|
|
47
|
+
scenariosVerified,
|
|
48
|
+
uniqueTestAnchors: anchors.size,
|
|
49
|
+
flows,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function pct(n, total) {
|
|
53
|
+
if (total === 0)
|
|
54
|
+
return "n/a";
|
|
55
|
+
return `${Math.round((n / total) * 100)}%`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Plain-text report, one fact per line, shaped to drop into the same job
|
|
59
|
+
* summary as `check`/`reconcile` output. Flows with zero covered loops are
|
|
60
|
+
* listed explicitly rather than summarized as a count — "C4 会话复活: 0/3"
|
|
61
|
+
* is the line that stops a reader assuming the whole model is anchored.
|
|
62
|
+
*/
|
|
63
|
+
export function formatCoverage(coverage) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
// First line, not a footnote: a reader who stops after the headline number
|
|
66
|
+
// must not walk away with a figure that a broken file silently moved.
|
|
67
|
+
if (coverage.parseErrors > 0) {
|
|
68
|
+
lines.push(`⚠ ${coverage.parseErrors} model file(s) failed to parse — every count below is a lower bound on a partial graph, and the percentages may read HIGHER than reality (run \`loopgraph check\` for the ids)`);
|
|
69
|
+
}
|
|
70
|
+
lines.push(`model coverage: ${coverage.loops} loop(s), ${coverage.junctions} junction(s), ${coverage.scenarios} scenario(s)`, ` loops with anchors: ${coverage.loopsWithAnchors}/${coverage.loops} (${pct(coverage.loopsWithAnchors, coverage.loops)})`, ` loops with scenarios: ${coverage.loopsWithScenarios}/${coverage.loops} (${pct(coverage.loopsWithScenarios, coverage.loops)})`);
|
|
71
|
+
if (coverage.loopsOnlyViaSelector > 0) {
|
|
72
|
+
lines.push(` (+ ${coverage.loopsOnlyViaSelector} loop(s) selected only via an applies_to selector — resolved at query time, not attached to the loop)`);
|
|
73
|
+
}
|
|
74
|
+
lines.push(` junctions with scenarios: ${coverage.junctionsWithScenarios}/${coverage.junctions}`, ` scenarios verified_by: ${coverage.scenariosVerified}/${coverage.scenarios}, ${coverage.uniqueTestAnchors} distinct test anchor(s)`);
|
|
75
|
+
if (coverage.flows.length > 0) {
|
|
76
|
+
lines.push(" per-flow (traversed loops with >=1 scenario):");
|
|
77
|
+
for (const flow of coverage.flows) {
|
|
78
|
+
const mark = flow.loopsWithScenarios === 0 && flow.loops > 0 ? " ← no behavior modeled" : "";
|
|
79
|
+
lines.push(` ${flow.id} ${flow.title}: ${flow.loopsWithScenarios}/${flow.loops}${mark}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=coverage.js.map
|
package/package.json
CHANGED