loopgraph 0.2.0 → 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 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)、实现事实对账、`--diff` 增量、内容寻址共享缓存(跨 worktree)。全量亚秒级。
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
@@ -1,4 +1,4 @@
1
- import type { ImplementationFact, SignalKind } from "../../adapters/agenthub/extract.js";
1
+ import type { Adapter, ImplementationFact, SignalKind } from "../../adapters/types.js";
2
2
  import { type Inv1CheckResult } from "../../validate/inv1/check.js";
3
3
  import type { T0Result } from "../../validate/types.js";
4
4
  export declare const SNAPSHOT_SCHEMA_VERSION: 1;
@@ -98,6 +98,8 @@ export interface SnapshotOptions {
98
98
  repoRoot?: string | undefined;
99
99
  /** Machine cache dir for facts (B3); null disables. Default: the shared cache. */
100
100
  cacheDir?: string | null | undefined;
101
+ /** The adapter supplying the facts (default: agenthub). Threaded to runFacts. */
102
+ adapter?: Adapter | undefined;
101
103
  /** Injected for reproducibility; defaults to now. */
102
104
  generatedAt?: string | undefined;
103
105
  /** Injected for tests; defaults to `git rev-parse HEAD` of repoRoot. */
Binary file
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
@@ -48,6 +51,27 @@ function readStringFlag(flags, name) {
48
51
  return { value: raw };
49
52
  return { value: undefined, error: `--${name} requires a value` };
50
53
  }
54
+ /**
55
+ * Resolve `--adapter <name>` for the fact-extracting commands (facts /
56
+ * reconcile / snapshot). Returns `{}` (no `adapter` key) when the flag is absent
57
+ * — the caller then falls back to the default adapter — or `{ error: true }`
58
+ * after reporting a malformed flag / unknown adapter name.
59
+ */
60
+ function resolveAdapterFlag(flags, io) {
61
+ const f = readStringFlag(flags, "adapter");
62
+ if (f.error) {
63
+ io.error(`${f.error}. ${USAGE}`);
64
+ return { error: true };
65
+ }
66
+ if (f.value === undefined)
67
+ return {}; // no --adapter → caller uses the default
68
+ const adapter = getAdapter(f.value);
69
+ if (!adapter) {
70
+ io.error(`unknown adapter "${f.value}" (available: ${adapterNames().join(", ")})`);
71
+ return { error: true };
72
+ }
73
+ return { adapter };
74
+ }
51
75
  /**
52
76
  * Core dispatch, factored out of the process entry point so tests can
53
77
  * drive it in-process (real filesystem via temp dirs, no subprocess
@@ -217,22 +241,12 @@ export async function run(argv, io) {
217
241
  // root, defaulting to cwd). `--no-cache` disables the B3 content cache.
218
242
  // `--adapter <name>` selects the target stack's adapter (default agenthub).
219
243
  const repo = positionals[0] ?? process.cwd();
220
- const adapterFlag = readStringFlag(flags, "adapter");
221
- if (adapterFlag.error) {
222
- io.error(`${adapterFlag.error}. ${USAGE}`);
244
+ const factsAdapter = resolveAdapterFlag(flags, io);
245
+ if ("error" in factsAdapter)
223
246
  return 1;
224
- }
225
- let adapter;
226
- if (adapterFlag.value !== undefined) {
227
- adapter = getAdapter(adapterFlag.value);
228
- if (!adapter) {
229
- io.error(`unknown adapter "${adapterFlag.value}" (available: ${adapterNames().join(", ")})`);
230
- return 1;
231
- }
232
- }
233
247
  const result = await runFacts(repo, {
234
248
  ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
235
- ...(adapter ? { adapter } : {}),
249
+ ...(factsAdapter.adapter ? { adapter: factsAdapter.adapter } : {}),
236
250
  });
237
251
  if (!result.ran) {
238
252
  io.log(`⚠ facts extraction skipped: ${result.skippedReason}`);
@@ -264,8 +278,14 @@ export async function run(argv, io) {
264
278
  io.error(`reconcile needs --repo-root <repo>. ${USAGE}`);
265
279
  return 1;
266
280
  }
281
+ const reconcileAdapter = resolveAdapterFlag(flags, io);
282
+ if ("error" in reconcileAdapter)
283
+ return 1;
267
284
  const model = await loadModel(`${targetDir}/model`);
268
- const factsResult = await runFacts(repoRootFlag.value, flags["no-cache"] === true ? { cacheDir: null } : {});
285
+ const factsResult = await runFacts(repoRootFlag.value, {
286
+ ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
287
+ ...(reconcileAdapter.adapter ? { adapter: reconcileAdapter.adapter } : {}),
288
+ });
269
289
  if (!factsResult.ran) {
270
290
  io.log(`⚠ reconcile skipped: ${factsResult.skippedReason}`);
271
291
  return 0;
@@ -285,6 +305,20 @@ export async function run(argv, io) {
285
305
  `${coveredFiles.length} model-covered file(s)`);
286
306
  return flags.strict === true && unregistered.length > 0 ? 1 : 0;
287
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
+ }
288
322
  case "snapshot": {
289
323
  // T2 nightly full-scan artifact (Proposal 006 D2). NEVER a PR gate
290
324
  // (001 §6): this always exits 0 — drift is reported, not failed on.
@@ -297,9 +331,13 @@ export async function run(argv, io) {
297
331
  return 1;
298
332
  }
299
333
  }
334
+ const snapshotAdapter = resolveAdapterFlag(flags, io);
335
+ if ("error" in snapshotAdapter)
336
+ return 1;
300
337
  const snapshot = await runSnapshot(targetDir, {
301
338
  repoRoot: repoRootFlag.value,
302
339
  ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
340
+ ...(snapshotAdapter.adapter ? { adapter: snapshotAdapter.adapter } : {}),
303
341
  });
304
342
  io.log(renderSnapshotSummary(snapshot));
305
343
  if (driftFlag.value) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopgraph",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Model-driven engineering control system: behavioral model as spec source of truth, code/tests/observability as projections and evidence.",
5
5
  "type": "module",
6
6
  "license": "MIT",