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,35 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadModel } from "../../loader/load-model.js";
|
|
3
|
+
import { writeSideChannel } from "../../query/side-channel.js";
|
|
4
|
+
import { sliceModel } from "../../query/slice.js";
|
|
5
|
+
import { renderSliceMarkdown, summarizeSlice } from "../../query/summary.js";
|
|
6
|
+
const DEFAULT_DEPTH = 2;
|
|
7
|
+
/**
|
|
8
|
+
* `loopgraph inspect <node-id>`: slice the model around one node and write a
|
|
9
|
+
* staleness-stamped side-channel file, returning a compact summary for stdout
|
|
10
|
+
* (proposal 001 §4.2 shape: summary + workspace file path). Throws on an
|
|
11
|
+
* unknown id or unparseable model — the CLI turns those into exit code 1.
|
|
12
|
+
*/
|
|
13
|
+
export async function runInspect(targetDir, nodeId, options = {}) {
|
|
14
|
+
const depth = options.depth ?? DEFAULT_DEPTH;
|
|
15
|
+
if (!Number.isInteger(depth) || depth < 0) {
|
|
16
|
+
throw new Error(`--depth must be a non-negative integer, got "${depth}"`);
|
|
17
|
+
}
|
|
18
|
+
const load = await loadModel(join(targetDir, "model"));
|
|
19
|
+
if (load.parseErrors.length > 0) {
|
|
20
|
+
throw new Error(`model has ${load.parseErrors.length} parse error(s) — run "loopgraph check" first`);
|
|
21
|
+
}
|
|
22
|
+
const slice = sliceModel(load.graph, nodeId, depth);
|
|
23
|
+
if (!slice) {
|
|
24
|
+
throw new Error(`unknown node id "${nodeId}" — not a feature/flow/loop/junction/scenario/debt`);
|
|
25
|
+
}
|
|
26
|
+
// node ids match the id-regex charset (L1 / C1 / J-x / GWT-x), all filename-safe.
|
|
27
|
+
const { outputPath, staleWarning } = await writeSideChannel(targetDir, `inspect-${nodeId}`, (banner) => renderSliceMarkdown(slice, banner));
|
|
28
|
+
return {
|
|
29
|
+
rootId: nodeId,
|
|
30
|
+
outputPath,
|
|
31
|
+
summary: summarizeSlice(slice, outputPath),
|
|
32
|
+
...(staleWarning ? { staleWarning } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=inspect.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ImplementationFact, SignalKind } from "../../adapters/agenthub/extract.js";
|
|
2
|
+
import { type Inv1CheckResult } from "../../validate/inv1/check.js";
|
|
3
|
+
import type { T0Result } from "../../validate/types.js";
|
|
4
|
+
export declare const SNAPSHOT_SCHEMA_VERSION: 1;
|
|
5
|
+
/**
|
|
6
|
+
* A fact's stable identity for drift comparison. Deliberately DROPS the source
|
|
7
|
+
* `line` — the extractor documents line as "not a stable anchor", so a cosmetic
|
|
8
|
+
* line shift must NOT register as drift. `detail`/`unanalyzable` ARE compared:
|
|
9
|
+
* a queue that becomes env-suffixed, or a name that goes unanalyzable, is a real
|
|
10
|
+
* change worth surfacing.
|
|
11
|
+
*/
|
|
12
|
+
export interface FactDigest {
|
|
13
|
+
signal: SignalKind;
|
|
14
|
+
name: string;
|
|
15
|
+
filePath: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
unanalyzable?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The T2 nightly artifact (Proposal 006 D2). A machine-readable full-scan
|
|
21
|
+
* snapshot: the facts inventory plus T0/INV-1 health, stamped with the adapter
|
|
22
|
+
* version and the scanned commit. Two uses: (1) a PR job reads it as a warm
|
|
23
|
+
* starting point instead of re-scanning from cold; (2) two snapshots diff into a
|
|
24
|
+
* drift report. 001 §6 red line: T2 NEVER gates a PR — this command is
|
|
25
|
+
* standalone and `check` never calls it.
|
|
26
|
+
*/
|
|
27
|
+
export interface Snapshot {
|
|
28
|
+
schemaVersion: typeof SNAPSHOT_SCHEMA_VERSION;
|
|
29
|
+
/** The adapter that produced the facts — a bump here explains fact churn that isn't real drift. */
|
|
30
|
+
adapterVersion: string;
|
|
31
|
+
/** HEAD of the scanned repo when known (git rev-parse), else undefined. */
|
|
32
|
+
commit?: string;
|
|
33
|
+
/** ISO timestamp; injected so the artifact is reproducible in tests. */
|
|
34
|
+
generatedAt: string;
|
|
35
|
+
t0: {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
errors: number;
|
|
38
|
+
warnings: number;
|
|
39
|
+
};
|
|
40
|
+
inv1: {
|
|
41
|
+
ran: boolean;
|
|
42
|
+
skippedReason?: string;
|
|
43
|
+
violations: number;
|
|
44
|
+
unanalyzable: number;
|
|
45
|
+
writePoints: number;
|
|
46
|
+
};
|
|
47
|
+
/** Sorted fact inventory (identity fields only — see FactDigest). */
|
|
48
|
+
facts: FactDigest[];
|
|
49
|
+
factCount: number;
|
|
50
|
+
timingMs: number;
|
|
51
|
+
}
|
|
52
|
+
export interface SnapshotDrift {
|
|
53
|
+
/** true when the adapter version differs — fact changes below may be extractor churn, not real drift. */
|
|
54
|
+
adapterBumped: boolean;
|
|
55
|
+
addedFacts: FactDigest[];
|
|
56
|
+
removedFacts: FactDigest[];
|
|
57
|
+
/** same identity (signal+name+filePath), different detail/unanalyzable. */
|
|
58
|
+
changedFacts: {
|
|
59
|
+
before: FactDigest;
|
|
60
|
+
after: FactDigest;
|
|
61
|
+
}[];
|
|
62
|
+
t0Delta: {
|
|
63
|
+
errors: number;
|
|
64
|
+
warnings: number;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* INV-1 deltas. `unanalyzable` matters as much as `violations`: a write point
|
|
68
|
+
* the canonical-writer scan can no longer analyze is a real loss of coverage,
|
|
69
|
+
* not a no-op — it must not report as "no drift".
|
|
70
|
+
*/
|
|
71
|
+
inv1Delta: {
|
|
72
|
+
violations: number;
|
|
73
|
+
unanalyzable: number;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* true when nothing in the TARGET changed (facts, T0, INV-1). An adapter
|
|
77
|
+
* version bump alone does NOT make it dirty (it's a tooling change, not target
|
|
78
|
+
* drift) — but `renderDrift` still surfaces the bump so it's never hidden.
|
|
79
|
+
*/
|
|
80
|
+
clean: boolean;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Assemble a Snapshot from already-run scan results. PURE — all non-determinism
|
|
84
|
+
* (commit, timestamp, adapter version) is passed in, so it is fully unit-testable.
|
|
85
|
+
*/
|
|
86
|
+
export declare function buildSnapshot(meta: {
|
|
87
|
+
adapterVersion: string;
|
|
88
|
+
commit?: string | undefined;
|
|
89
|
+
generatedAt: string;
|
|
90
|
+
timingMs: number;
|
|
91
|
+
}, t0: T0Result, facts: ImplementationFact[], inv1: Inv1CheckResult | undefined): Snapshot;
|
|
92
|
+
/**
|
|
93
|
+
* Diff two snapshots into a drift report. PURE. Compares the fact inventory by
|
|
94
|
+
* stable identity (added/removed/value-changed) and the T0/INV-1 counts.
|
|
95
|
+
*/
|
|
96
|
+
export declare function diffSnapshots(prev: Snapshot, curr: Snapshot): SnapshotDrift;
|
|
97
|
+
export interface SnapshotOptions {
|
|
98
|
+
repoRoot?: string | undefined;
|
|
99
|
+
/** Machine cache dir for facts (B3); null disables. Default: the shared cache. */
|
|
100
|
+
cacheDir?: string | null | undefined;
|
|
101
|
+
/** Injected for reproducibility; defaults to now. */
|
|
102
|
+
generatedAt?: string | undefined;
|
|
103
|
+
/** Injected for tests; defaults to `git rev-parse HEAD` of repoRoot. */
|
|
104
|
+
commit?: string | undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Run a full T2 scan and build the snapshot (Proposal 006 D2): T0 over the
|
|
108
|
+
* model, facts over the repo (which also WARMS the machine cache — the whole
|
|
109
|
+
* point of the nightly run), and a full INV-1 pass. Returns the snapshot; the
|
|
110
|
+
* CLI persists it. Never called from the PR-gate `check` path (001 §6).
|
|
111
|
+
*/
|
|
112
|
+
export declare function runSnapshot(targetDir: string, options?: SnapshotOptions): Promise<Snapshot>;
|
|
113
|
+
/** Load a previously-written snapshot (for drift comparison). undefined if absent/unreadable. */
|
|
114
|
+
export declare function loadSnapshot(path: string): Promise<Snapshot | undefined>;
|
|
115
|
+
/** One-line human summary of a snapshot's health. */
|
|
116
|
+
export declare function renderSnapshotSummary(s: Snapshot): string;
|
|
117
|
+
/** Human drift report between two snapshots (Proposal 006 D2). */
|
|
118
|
+
export declare function renderDrift(drift: SnapshotDrift): string[];
|
|
119
|
+
/** Persist a snapshot as pretty JSON, atomically (temp file + rename). */
|
|
120
|
+
export declare function writeSnapshot(path: string, snapshot: Snapshot): Promise<void>;
|
|
121
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
Binary file
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { StalenessStamp } from "../../staleness.js";
|
|
2
|
+
import type { MermaidValidationResult } from "../../views/validate-mermaid.js";
|
|
3
|
+
export interface ViewOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Actually render the generated mermaid with mmdc and report whether
|
|
6
|
+
* it parses (Decision record 004 技术点 5). Off by default: mmdc needs
|
|
7
|
+
* a real puppeteer-launched browser, which isn't a fair default
|
|
8
|
+
* runtime requirement for every consumer of `loopgraph view` — see
|
|
9
|
+
* src/views/validate-mermaid.ts's docstring.
|
|
10
|
+
*/
|
|
11
|
+
validate?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ViewResult {
|
|
14
|
+
flowId: string;
|
|
15
|
+
outputPath: string;
|
|
16
|
+
mermaid: string;
|
|
17
|
+
stamp: StalenessStamp;
|
|
18
|
+
validation?: MermaidValidationResult;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Generates a mermaid view for one Flow (Phase 1 scope: C1 only, per
|
|
22
|
+
* proposal 001 §12) and writes it, staleness-stamped, to the
|
|
23
|
+
* `.loopgraph/ws/` side-channel (proposal 001 §5 — query-side-channel
|
|
24
|
+
* output, gitignored, regenerated on demand, not a cache).
|
|
25
|
+
*
|
|
26
|
+
* Always writes the file, even when `--validate` reports the diagram
|
|
27
|
+
* invalid — same posture as `loopgraph check`, which reports violations
|
|
28
|
+
* rather than refusing to run: hiding the broken output would remove
|
|
29
|
+
* the one artifact useful for debugging what went wrong. The CLI layer
|
|
30
|
+
* (src/cli/run.ts) is what turns an "invalid" validation result into a
|
|
31
|
+
* non-zero exit code.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runView(targetDir: string, flowId: string, options?: ViewOptions): Promise<ViewResult>;
|
|
34
|
+
//# sourceMappingURL=view.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadModel } from "../../loader/load-model.js";
|
|
4
|
+
import { computeStalenessStamp, formatStalenessBanner } from "../../staleness.js";
|
|
5
|
+
import { renderFlowMermaid } from "../../views/flow-mermaid.js";
|
|
6
|
+
import { validateMermaid } from "../../views/validate-mermaid.js";
|
|
7
|
+
/**
|
|
8
|
+
* Generates a mermaid view for one Flow (Phase 1 scope: C1 only, per
|
|
9
|
+
* proposal 001 §12) and writes it, staleness-stamped, to the
|
|
10
|
+
* `.loopgraph/ws/` side-channel (proposal 001 §5 — query-side-channel
|
|
11
|
+
* output, gitignored, regenerated on demand, not a cache).
|
|
12
|
+
*
|
|
13
|
+
* Always writes the file, even when `--validate` reports the diagram
|
|
14
|
+
* invalid — same posture as `loopgraph check`, which reports violations
|
|
15
|
+
* rather than refusing to run: hiding the broken output would remove
|
|
16
|
+
* the one artifact useful for debugging what went wrong. The CLI layer
|
|
17
|
+
* (src/cli/run.ts) is what turns an "invalid" validation result into a
|
|
18
|
+
* non-zero exit code.
|
|
19
|
+
*/
|
|
20
|
+
export async function runView(targetDir, flowId, options = {}) {
|
|
21
|
+
const modelDir = join(targetDir, "model");
|
|
22
|
+
const load = await loadModel(modelDir);
|
|
23
|
+
if (load.parseErrors.length > 0) {
|
|
24
|
+
throw new Error(`model has ${load.parseErrors.length} parse error(s) — run "loopgraph check" first`);
|
|
25
|
+
}
|
|
26
|
+
const mermaid = renderFlowMermaid(load.graph, flowId);
|
|
27
|
+
const stamp = await computeStalenessStamp(modelDir, targetDir);
|
|
28
|
+
const wsDir = join(targetDir, ".loopgraph", "ws");
|
|
29
|
+
await mkdir(wsDir, { recursive: true });
|
|
30
|
+
const outputPath = join(wsDir, `view-${flowId}.md`);
|
|
31
|
+
const body = [
|
|
32
|
+
formatStalenessBanner(stamp),
|
|
33
|
+
"",
|
|
34
|
+
`# ${flowId} view`,
|
|
35
|
+
"",
|
|
36
|
+
"```mermaid",
|
|
37
|
+
mermaid,
|
|
38
|
+
"```",
|
|
39
|
+
"",
|
|
40
|
+
].join("\n");
|
|
41
|
+
await writeFile(outputPath, body, "utf8");
|
|
42
|
+
const validation = options.validate ? await validateMermaid(mermaid) : undefined;
|
|
43
|
+
return { flowId, outputPath, mermaid, stamp, ...(validation ? { validation } : {}) };
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=view.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "./run.js";
|
|
3
|
+
const io = {
|
|
4
|
+
log: (line) => {
|
|
5
|
+
process.stdout.write(`${line}\n`);
|
|
6
|
+
},
|
|
7
|
+
error: (line) => {
|
|
8
|
+
process.stderr.write(`${line}\n`);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
run(process.argv.slice(2), io)
|
|
12
|
+
.then((exitCode) => {
|
|
13
|
+
process.exitCode = exitCode;
|
|
14
|
+
})
|
|
15
|
+
.catch((err) => {
|
|
16
|
+
io.error(err instanceof Error ? err.message : String(err));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
});
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface CliIO {
|
|
2
|
+
log: (line: string) => void;
|
|
3
|
+
error: (line: string) => void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Core dispatch, factored out of the process entry point so tests can
|
|
7
|
+
* drive it in-process (real filesystem via temp dirs, no subprocess
|
|
8
|
+
* spawning, no mocking process.argv/stdout).
|
|
9
|
+
*/
|
|
10
|
+
export declare function run(argv: string[], io: CliIO): Promise<number>;
|
|
11
|
+
//# sourceMappingURL=run.d.ts.map
|
package/dist/cli/run.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { runFacts } from "../adapters/agenthub/facts.js";
|
|
2
|
+
import { loadModel } from "../loader/load-model.js";
|
|
3
|
+
import { startMcpServer } from "../mcp/server.js";
|
|
4
|
+
import { runQuery } from "../query/run-query.js";
|
|
5
|
+
import { inv1ViolationsFrom } from "../validate/inv1/check.js";
|
|
6
|
+
import { reconcileFacts } from "../validate/unregistered.js";
|
|
7
|
+
import { parseFlags } from "./args.js";
|
|
8
|
+
import { runCheck } from "./commands/check.js";
|
|
9
|
+
import { runImport } from "./commands/import.js";
|
|
10
|
+
import { runInit } from "./commands/init.js";
|
|
11
|
+
import { runInspect } from "./commands/inspect.js";
|
|
12
|
+
import { diffSnapshots, loadSnapshot, renderDrift, renderSnapshotSummary, runSnapshot, writeSnapshot, } from "./commands/snapshot.js";
|
|
13
|
+
import { runView } from "./commands/view.js";
|
|
14
|
+
const QUERY_COMMANDS = [
|
|
15
|
+
"impact",
|
|
16
|
+
"plan",
|
|
17
|
+
"scenario",
|
|
18
|
+
"evidence",
|
|
19
|
+
"matrix",
|
|
20
|
+
];
|
|
21
|
+
function formatViolation(v) {
|
|
22
|
+
const marker = v.severity === "error" ? "✗" : "⚠"; // ✗ / ⚠
|
|
23
|
+
const loc = v.file ? ` [${v.file}]` : v.nodeId ? ` [${v.nodeId}]` : "";
|
|
24
|
+
return `${marker} ${v.check}${loc}: ${v.message}`;
|
|
25
|
+
}
|
|
26
|
+
const USAGE = "usage: loopgraph <init|import|check> [dir] [--adapter name] [--repo-root path] [--strict-anchors] [--diff base-ref]\n" +
|
|
27
|
+
" loopgraph view <flow-id> [dir] [--validate]\n" +
|
|
28
|
+
" loopgraph inspect <node-id> [dir] [--depth n]\n" +
|
|
29
|
+
" loopgraph <impact|plan|scenario|evidence|matrix> <id> [dir]\n" +
|
|
30
|
+
" loopgraph mcp [dir] # start the stdio MCP server\n" +
|
|
31
|
+
" loopgraph facts [repo] # extract implementation facts (pg-boss queues, pollers)\n" +
|
|
32
|
+
" loopgraph snapshot [dir] --repo-root path [--out file] [--drift prior.json] # T2 nightly scan artifact";
|
|
33
|
+
/**
|
|
34
|
+
* Reads a `--flag value` string option. Returns `{ error }` when the flag
|
|
35
|
+
* was given with no value (parseFlags then records it as boolean `true`)
|
|
36
|
+
* — that's a malformed invocation (e.g. `--repo-root` as the last arg,
|
|
37
|
+
* or immediately followed by another `--flag`) and must be rejected
|
|
38
|
+
* loudly, not silently treated as "flag not given" (which previously let
|
|
39
|
+
* `check --repo-root` with a missing value quietly skip anchor-existence
|
|
40
|
+
* checking instead of erroring on the typo).
|
|
41
|
+
*/
|
|
42
|
+
function readStringFlag(flags, name) {
|
|
43
|
+
const raw = flags[name];
|
|
44
|
+
if (raw === undefined)
|
|
45
|
+
return { value: undefined };
|
|
46
|
+
if (typeof raw === "string")
|
|
47
|
+
return { value: raw };
|
|
48
|
+
return { value: undefined, error: `--${name} requires a value` };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Core dispatch, factored out of the process entry point so tests can
|
|
52
|
+
* drive it in-process (real filesystem via temp dirs, no subprocess
|
|
53
|
+
* spawning, no mocking process.argv/stdout).
|
|
54
|
+
*/
|
|
55
|
+
export async function run(argv, io) {
|
|
56
|
+
const [command, ...rest] = argv;
|
|
57
|
+
const { positionals, flags } = parseFlags(rest);
|
|
58
|
+
const targetDir = positionals[0] ?? process.cwd();
|
|
59
|
+
switch (command) {
|
|
60
|
+
case "init": {
|
|
61
|
+
const result = await runInit(targetDir);
|
|
62
|
+
for (const c of result.created)
|
|
63
|
+
io.log(`created ${c}`);
|
|
64
|
+
for (const s of result.skipped)
|
|
65
|
+
io.log(`skipped ${s} (already exists)`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
case "import": {
|
|
69
|
+
const adapterFlag = readStringFlag(flags, "adapter");
|
|
70
|
+
if (adapterFlag.error) {
|
|
71
|
+
io.error(`${adapterFlag.error}. ${USAGE}`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
const adapter = adapterFlag.value ?? "agenthub";
|
|
75
|
+
const result = await runImport(targetDir, adapter);
|
|
76
|
+
io.log(`imported "${result.adapter}" seed into ${result.destination}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
case "check": {
|
|
80
|
+
const repoRootFlag = readStringFlag(flags, "repo-root");
|
|
81
|
+
if (repoRootFlag.error) {
|
|
82
|
+
io.error(`${repoRootFlag.error}. ${USAGE}`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const repoRoot = repoRootFlag.value;
|
|
86
|
+
const strictAnchorExistence = flags["strict-anchors"] === true;
|
|
87
|
+
const diffFlag = readStringFlag(flags, "diff");
|
|
88
|
+
if (diffFlag.error) {
|
|
89
|
+
io.error(`${diffFlag.error}. ${USAGE}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const { t0, inv1, inv1ConfigError, diff, baselineViolations } = await runCheck(targetDir, {
|
|
93
|
+
repoRoot,
|
|
94
|
+
strictAnchorExistence,
|
|
95
|
+
diffBase: diffFlag.value,
|
|
96
|
+
});
|
|
97
|
+
const violations = [...t0.violations];
|
|
98
|
+
if (baselineViolations)
|
|
99
|
+
violations.push(...baselineViolations);
|
|
100
|
+
if (inv1)
|
|
101
|
+
violations.push(...inv1ViolationsFrom(inv1));
|
|
102
|
+
for (const v of violations)
|
|
103
|
+
io.log(formatViolation(v));
|
|
104
|
+
if (diff) {
|
|
105
|
+
if (!diff.changed) {
|
|
106
|
+
io.log(`⚠ --diff ${diff.baseRef}: could not resolve diff (bad ref / not git) — full scan`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
io.log(`--diff ${diff.baseRef}: ${diff.changed.length} changed file(s), ${diff.affected.length} affected model node(s)${diff.incremental ? " (INV-1 incremental)" : ""}`);
|
|
110
|
+
for (const a of diff.affected)
|
|
111
|
+
io.log(` affects ${a.nodeId} (${a.kind}) via ${a.anchor}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (inv1ConfigError)
|
|
115
|
+
io.error(`✗ inv1-config: ${inv1ConfigError}`);
|
|
116
|
+
if (inv1) {
|
|
117
|
+
if (!inv1.ran) {
|
|
118
|
+
io.log(`⚠ INV-1 scan skipped: ${inv1.skippedReason}`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const byVerdict = inv1.writePoints.reduce((acc, w) => {
|
|
122
|
+
acc[w.verdict] = (acc[w.verdict] ?? 0) + 1;
|
|
123
|
+
return acc;
|
|
124
|
+
}, {});
|
|
125
|
+
io.log(`INV-1 scan: ${inv1.filesScanned}/${inv1.candidateFiles} candidate file(s), ` +
|
|
126
|
+
`${byVerdict.allowed ?? 0} allowed / ${byVerdict.violation ?? 0} violation(s) / ${byVerdict.unanalyzable ?? 0} unanalyzable, ` +
|
|
127
|
+
`${inv1.timingMs.toFixed(0)}ms`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const errorCount = violations.filter((v) => v.severity === "error").length;
|
|
131
|
+
const warningCount = violations.length - errorCount;
|
|
132
|
+
const ok = errorCount === 0 && !inv1ConfigError;
|
|
133
|
+
io.log(ok
|
|
134
|
+
? `T0 passed (${warningCount} warning(s))`
|
|
135
|
+
: `T0 failed (${errorCount} error(s), ${warningCount} warning(s))`);
|
|
136
|
+
return ok ? 0 : 1;
|
|
137
|
+
}
|
|
138
|
+
case "view": {
|
|
139
|
+
// Unlike init/import/check, positionals[0] here is the required
|
|
140
|
+
// <flow-id>, not the target dir — the shared `targetDir` computed
|
|
141
|
+
// above (which assumes positionals[0] is a dir) doesn't apply.
|
|
142
|
+
const flowId = positionals[0];
|
|
143
|
+
if (!flowId) {
|
|
144
|
+
io.error(`missing <flow-id>. ${USAGE}`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const viewTargetDir = positionals[1] ?? process.cwd();
|
|
148
|
+
const validate = flags.validate === true;
|
|
149
|
+
let result;
|
|
150
|
+
try {
|
|
151
|
+
result = await runView(viewTargetDir, flowId, { validate });
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
io.error(err instanceof Error ? err.message : String(err));
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
io.log(`wrote ${result.outputPath}`);
|
|
158
|
+
if (!result.validation)
|
|
159
|
+
return 0;
|
|
160
|
+
if (result.validation.status === "valid") {
|
|
161
|
+
io.log("mermaid validation: ok");
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (result.validation.status === "unavailable") {
|
|
165
|
+
io.log(`⚠ mermaid validation skipped: ${result.validation.reason}`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
io.log(`✗ mermaid validation failed: ${result.validation.error}`);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
case "inspect": {
|
|
172
|
+
// positionals[0] is the required <node-id>, not the target dir.
|
|
173
|
+
const nodeId = positionals[0];
|
|
174
|
+
if (!nodeId) {
|
|
175
|
+
io.error(`missing <node-id>. ${USAGE}`);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
const inspectTargetDir = positionals[1] ?? process.cwd();
|
|
179
|
+
const depthFlag = readStringFlag(flags, "depth");
|
|
180
|
+
if (depthFlag.error) {
|
|
181
|
+
io.error(`${depthFlag.error}. ${USAGE}`);
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
let depth;
|
|
185
|
+
if (depthFlag.value !== undefined) {
|
|
186
|
+
depth = Number(depthFlag.value);
|
|
187
|
+
if (!Number.isInteger(depth) || depth < 0) {
|
|
188
|
+
io.error(`--depth must be a non-negative integer, got "${depthFlag.value}". ${USAGE}`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = await runInspect(inspectTargetDir, nodeId, depth === undefined ? {} : { depth });
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
io.error(err instanceof Error ? err.message : String(err));
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
if (result.staleWarning)
|
|
201
|
+
io.log(`⚠ ${result.staleWarning}`);
|
|
202
|
+
io.log(result.summary);
|
|
203
|
+
io.log(`wrote ${result.outputPath}`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
case "mcp": {
|
|
207
|
+
// Long-lived stdio server: it owns stdout for the JSON-RPC protocol, so
|
|
208
|
+
// nothing else may write there. targetDir is positionals[0] (the dir with
|
|
209
|
+
// model/), defaulting to cwd.
|
|
210
|
+
const mcpTargetDir = positionals[0] ?? process.cwd();
|
|
211
|
+
await startMcpServer(mcpTargetDir);
|
|
212
|
+
return 0; // returns when the transport closes
|
|
213
|
+
}
|
|
214
|
+
case "facts": {
|
|
215
|
+
// Extract implementation facts from a target repo (positionals[0] = repo
|
|
216
|
+
// root, defaulting to cwd). `--no-cache` disables the B3 content cache.
|
|
217
|
+
const repo = positionals[0] ?? process.cwd();
|
|
218
|
+
const result = await runFacts(repo, flags["no-cache"] === true ? { cacheDir: null } : {});
|
|
219
|
+
if (!result.ran) {
|
|
220
|
+
io.log(`⚠ facts extraction skipped: ${result.skippedReason}`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
const bySignal = {};
|
|
224
|
+
for (const f of result.facts) {
|
|
225
|
+
const list = bySignal[f.signal] ?? [];
|
|
226
|
+
list.push(f);
|
|
227
|
+
bySignal[f.signal] = list;
|
|
228
|
+
}
|
|
229
|
+
for (const [signal, list] of Object.entries(bySignal)) {
|
|
230
|
+
io.log(`${signal} (${list.length}):`);
|
|
231
|
+
for (const f of list) {
|
|
232
|
+
const detail = f.detail ? ` [${f.detail}${f.unanalyzable ? ", unanalyzable" : ""}]` : "";
|
|
233
|
+
io.log(` ${f.name} ${f.filePath}:${f.line}${detail}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
io.log(`extracted ${result.facts.length} fact(s) from ${result.filesScanned}/${result.candidateFiles} candidate file(s), ${result.timingMs.toFixed(0)}ms`);
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
case "reconcile": {
|
|
240
|
+
// C2 T1 (advisory): which extracted facts no model node registers.
|
|
241
|
+
// `loopgraph reconcile <model-dir> --repo-root <repo>`. Exits 0 always
|
|
242
|
+
// (T1 is report-tier until C3 promotes it); `--strict` flips unregistered
|
|
243
|
+
// facts into a non-zero exit for opt-in local use.
|
|
244
|
+
const repoRootFlag = readStringFlag(flags, "repo-root");
|
|
245
|
+
if (repoRootFlag.error || !repoRootFlag.value) {
|
|
246
|
+
io.error(`reconcile needs --repo-root <repo>. ${USAGE}`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
const model = await loadModel(`${targetDir}/model`);
|
|
250
|
+
const factsResult = await runFacts(repoRootFlag.value, flags["no-cache"] === true ? { cacheDir: null } : {});
|
|
251
|
+
if (!factsResult.ran) {
|
|
252
|
+
io.log(`⚠ reconcile skipped: ${factsResult.skippedReason}`);
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
const { registered, dormantSuppressed, unregistered, coveredFiles, unmatchedConsumedQueues } = reconcileFacts(factsResult.facts, model.graph);
|
|
256
|
+
for (const f of unregistered)
|
|
257
|
+
io.log(`⚠ unregistered ${f.signal}: ${f.name} ${f.filePath}:${f.line}`);
|
|
258
|
+
for (const f of dormantSuppressed)
|
|
259
|
+
io.log(`· dormant-suppressed ${f.signal}: ${f.name} ${f.filePath}:${f.line}`);
|
|
260
|
+
for (const q of unmatchedConsumedQueues)
|
|
261
|
+
io.log(`⚠ dangling consumes_queues: "${q}" matches no extracted queue (typo/stale?)`);
|
|
262
|
+
const total = factsResult.facts.length;
|
|
263
|
+
const pct = total === 0 ? 0 : Math.round((unregistered.length / total) * 100);
|
|
264
|
+
const dormantNote = dormantSuppressed.length > 0 ? `, ${dormantSuppressed.length} dormant-suppressed` : "";
|
|
265
|
+
// registered + dormantSuppressed + unregistered === total (disjoint).
|
|
266
|
+
io.log(`reconcile: ${registered.length}/${total} fact(s) registered${dormantNote}, ${unregistered.length} unregistered (${pct}%), ` +
|
|
267
|
+
`${coveredFiles.length} model-covered file(s)`);
|
|
268
|
+
return flags.strict === true && unregistered.length > 0 ? 1 : 0;
|
|
269
|
+
}
|
|
270
|
+
case "snapshot": {
|
|
271
|
+
// T2 nightly full-scan artifact (Proposal 006 D2). NEVER a PR gate
|
|
272
|
+
// (001 §6): this always exits 0 — drift is reported, not failed on.
|
|
273
|
+
const repoRootFlag = readStringFlag(flags, "repo-root");
|
|
274
|
+
const outFlag = readStringFlag(flags, "out");
|
|
275
|
+
const driftFlag = readStringFlag(flags, "drift");
|
|
276
|
+
for (const f of [repoRootFlag, outFlag, driftFlag]) {
|
|
277
|
+
if (f.error) {
|
|
278
|
+
io.error(`${f.error}. ${USAGE}`);
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const snapshot = await runSnapshot(targetDir, {
|
|
283
|
+
repoRoot: repoRootFlag.value,
|
|
284
|
+
...(flags["no-cache"] === true ? { cacheDir: null } : {}),
|
|
285
|
+
});
|
|
286
|
+
io.log(renderSnapshotSummary(snapshot));
|
|
287
|
+
if (driftFlag.value) {
|
|
288
|
+
const prior = await loadSnapshot(driftFlag.value);
|
|
289
|
+
if (!prior) {
|
|
290
|
+
io.log(`⚠ --drift: no readable prior snapshot at ${driftFlag.value} — skipping drift`);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
for (const line of renderDrift(diffSnapshots(prior, snapshot)))
|
|
294
|
+
io.log(line);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const outPath = outFlag.value ?? `${targetDir}/.loopgraph/snapshot.json`;
|
|
298
|
+
await writeSnapshot(outPath, snapshot);
|
|
299
|
+
io.log(`wrote ${outPath}`);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
default: {
|
|
303
|
+
if (command && QUERY_COMMANDS.includes(command)) {
|
|
304
|
+
// impact / plan / scenario / evidence <id> [dir]
|
|
305
|
+
const id = positionals[0];
|
|
306
|
+
if (!id) {
|
|
307
|
+
io.error(`missing <id> for \`${command}\`. ${USAGE}`);
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
const queryTargetDir = positionals[1] ?? process.cwd();
|
|
311
|
+
let result;
|
|
312
|
+
try {
|
|
313
|
+
result = await runQuery(queryTargetDir, command, id);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
io.error(err instanceof Error ? err.message : String(err));
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
if (result.staleWarning)
|
|
320
|
+
io.log(`⚠ ${result.staleWarning}`);
|
|
321
|
+
io.log(result.summary);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
io.error(`unknown command "${command ?? ""}". ${USAGE}`);
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure error-code → message mapping, factored out of findYamlFiles so
|
|
3
|
+
* every branch (ENOENT/ENOTDIR/EACCES/EPERM/unknown) is directly
|
|
4
|
+
* unit-testable without needing to actually reproduce each condition on
|
|
5
|
+
* a real filesystem — reproducing EACCES portably (across macOS/Linux/CI
|
|
6
|
+
* containers, some of which run as root and ignore chmod) is exactly the
|
|
7
|
+
* kind of flaky test this sidesteps. Returns undefined for an
|
|
8
|
+
* unrecognized/missing error code, signaling "rethrow as-is".
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatModelDirReadError(dir: string, code: string | undefined): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* An unreadable model directory (missing, wrong permissions, or not
|
|
13
|
+
* actually a directory) is a setup error, not "zero nodes". Treating it
|
|
14
|
+
* as an empty model would make `runT0` trivially pass with zero
|
|
15
|
+
* violations — a false "T0 passed" that implies something was checked
|
|
16
|
+
* when nothing was. This surfaces a clear, actionable error instead of
|
|
17
|
+
* whatever raw errno a bare `readdir` would throw.
|
|
18
|
+
*
|
|
19
|
+
* Lives in its own module (not inlined in load-model.ts) because it's
|
|
20
|
+
* reused by src/staleness.ts (computeModelContentHash needs the same
|
|
21
|
+
* "every *.yaml/*.yml file under a model dir" listing to hash) — a
|
|
22
|
+
* second, unrelated caller that has no business depending on
|
|
23
|
+
* `loadModel`'s own orchestration module.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findYamlFiles(dir: string): Promise<string[]>;
|
|
26
|
+
//# sourceMappingURL=find-yaml-files.d.ts.map
|