loopgraph 0.1.0 → 0.2.1

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.
@@ -22,19 +22,8 @@
22
22
  * didn't change still needs re-extraction if the extractor did).
23
23
  */
24
24
  export declare const ADAPTER_VERSION = "agenthub-facts-1";
25
- export type SignalKind = "pg_boss_queue" | "setinterval_poller";
26
- export interface ImplementationFact {
27
- signal: SignalKind;
28
- /** The queue name, or the poller's enclosing function/file marker. */
29
- name: string;
30
- filePath: string;
31
- /** 1-based line for a human reading the report — not a stable anchor. */
32
- line: number;
33
- /** Extra context: e.g. "env-suffixed" for a QUEUE_SUFFIX template, or the interval. */
34
- detail?: string;
35
- /** true when the fact's key part (queue name / interval) couldn't be read statically. */
36
- unanalyzable?: boolean;
37
- }
25
+ export type { ImplementationFact, SignalKind } from "../types.js";
26
+ import type { ImplementationFact } from "../types.js";
38
27
  /**
39
28
  * pg-boss queue names. In this target stack the authoritative names live in
40
29
  * `const *_QUEUE = <string | template>` declarations (e.g.
@@ -1,5 +1,5 @@
1
1
  import type { Adapter } from "../types.js";
2
- import type { ImplementationFact } from "./extract.js";
2
+ import type { ImplementationFact } from "../types.js";
3
3
  export interface FactsOptions {
4
4
  /**
5
5
  * Machine-level content cache dir (B3). Defaults to `~/.cache/loopgraph`;
@@ -1,12 +1,15 @@
1
1
  import { agenthubAdapter } from "./agenthub/index.js";
2
+ import { rushAdapter } from "./rush/index.js";
2
3
  /**
3
4
  * The adapter registry (E1): the single place the engine resolves an adapter by
4
5
  * name. A new target stack registers here and everything else — the fact
5
- * runner, the cache key, the INV-1 default config — flows through the Adapter
6
- * interface without further engine changes.
6
+ * runner, the cache key — flows through the Adapter interface without further
7
+ * engine changes. `rush` (GATE-3) is the proof: registering it here is the ONLY
8
+ * engine-side change a second target required.
7
9
  */
8
10
  const ADAPTERS = {
9
11
  [agenthubAdapter.name]: agenthubAdapter,
12
+ [rushAdapter.name]: rushAdapter,
10
13
  };
11
14
  export function getAdapter(name) {
12
15
  return ADAPTERS[name];
@@ -0,0 +1,36 @@
1
+ import type { ImplementationFact } from "../types.js";
2
+ /**
3
+ * rush adapter — implementation-fact extraction (Proposal 006 E2 / GATE-3, the
4
+ * second target stack). PURE functions of `(filePath, content)`, an in-memory
5
+ * `ts.createSourceFile` parse, no I/O — same cacheable posture as the agenthub
6
+ * adapter.
7
+ *
8
+ * rush's background-signal profile differs from agenthub's: it has no pg-boss
9
+ * queue backend; async fan-out goes through Redis pub/sub channels, plus the
10
+ * usual setInterval pollers. So this adapter extracts (1) Redis channels and
11
+ * (2) setInterval pollers. It is fully independent of the agenthub adapter — no
12
+ * shared extractor logic (they're structurally similar but semantically distinct
13
+ * per-target extension points; extract a common core only once a 3rd adapter
14
+ * exists and the real invariants are known, not from n=2).
15
+ */
16
+ /** Bumped when THIS adapter's extractor logic changes — part of the B3 cache key. */
17
+ export declare const RUSH_ADAPTER_VERSION = "rush-facts-1";
18
+ /**
19
+ * Redis pub/sub channels. rush's convention is `const *channel* = <string |
20
+ * template>` referenced by identifier at `.publish(channel, ...)` /
21
+ * `.subscribe(channel)` call sites; a direct string literal AT the call site is
22
+ * also captured. The `*channel*` const-name convention is rush-adapter
23
+ * knowledge — exactly what an adapter is allowed to encode. Deduped by name.
24
+ */
25
+ export declare function extractRedisChannels(filePath: string, content: string): ImplementationFact[];
26
+ /**
27
+ * setInterval poller registrations — bare (`setInterval(...)`) or off an object
28
+ * (`globalThis.setInterval(...)`). Numeric-literal interval → detail; a
29
+ * variable/expression interval → unanalyzable. (setInterval is a Node global,
30
+ * not rush-specific — but kept local to this adapter rather than shared with
31
+ * agenthub until a 3rd adapter justifies extraction.)
32
+ */
33
+ export declare function extractPollers(filePath: string, content: string): ImplementationFact[];
34
+ /** Runs every rush extractor over one file. */
35
+ export declare function extractFacts(filePath: string, content: string): ImplementationFact[];
36
+ //# sourceMappingURL=extract.d.ts.map
@@ -0,0 +1,125 @@
1
+ import ts from "typescript";
2
+ /**
3
+ * rush adapter — implementation-fact extraction (Proposal 006 E2 / GATE-3, the
4
+ * second target stack). PURE functions of `(filePath, content)`, an in-memory
5
+ * `ts.createSourceFile` parse, no I/O — same cacheable posture as the agenthub
6
+ * adapter.
7
+ *
8
+ * rush's background-signal profile differs from agenthub's: it has no pg-boss
9
+ * queue backend; async fan-out goes through Redis pub/sub channels, plus the
10
+ * usual setInterval pollers. So this adapter extracts (1) Redis channels and
11
+ * (2) setInterval pollers. It is fully independent of the agenthub adapter — no
12
+ * shared extractor logic (they're structurally similar but semantically distinct
13
+ * per-target extension points; extract a common core only once a 3rd adapter
14
+ * exists and the real invariants are known, not from n=2).
15
+ */
16
+ /** Bumped when THIS adapter's extractor logic changes — part of the B3 cache key. */
17
+ export const RUSH_ADAPTER_VERSION = "rush-facts-1";
18
+ function lineOf(src, node) {
19
+ return src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1;
20
+ }
21
+ /**
22
+ * The static name of a string/template expression, or null if not statically
23
+ * known. Template statics are joined with "…" across the dynamic `${}` gaps —
24
+ * e.g. `` `events:${id}` `` → "events:"; a fully dynamic template → "<dynamic>".
25
+ */
26
+ function channelNameOf(expr) {
27
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr))
28
+ return { name: expr.text };
29
+ if (ts.isTemplateExpression(expr)) {
30
+ const staticParts = [expr.head.text, ...expr.templateSpans.map((s) => s.literal.text)].filter(Boolean);
31
+ if (staticParts.length === 0)
32
+ return { name: "<dynamic>", detail: "interpolated" };
33
+ return { name: staticParts.join("…"), detail: "interpolated" };
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Redis pub/sub channels. rush's convention is `const *channel* = <string |
39
+ * template>` referenced by identifier at `.publish(channel, ...)` /
40
+ * `.subscribe(channel)` call sites; a direct string literal AT the call site is
41
+ * also captured. The `*channel*` const-name convention is rush-adapter
42
+ * knowledge — exactly what an adapter is allowed to encode. Deduped by name.
43
+ */
44
+ export function extractRedisChannels(filePath, content) {
45
+ const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
46
+ const facts = [];
47
+ const seen = new Set();
48
+ const PUBSUB_METHODS = new Set(["publish", "subscribe"]);
49
+ const push = (name, node, detail) => {
50
+ if (seen.has(name))
51
+ return;
52
+ seen.add(name);
53
+ facts.push({
54
+ signal: "redis_channel",
55
+ name,
56
+ filePath,
57
+ line: lineOf(src, node),
58
+ ...(detail ? { detail } : {}),
59
+ });
60
+ };
61
+ function visit(node) {
62
+ // `const *channel* = <string | template>` — the authoritative channel-name decl.
63
+ // Case-INSENSITIVE "channel" (rush uses lowercase `channel` locals and
64
+ // SCREAMING_SNAKE `*_CHANNEL` consts alike).
65
+ if (ts.isVariableDeclaration(node) &&
66
+ ts.isIdentifier(node.name) &&
67
+ node.name.text.toLowerCase().includes("channel") &&
68
+ node.initializer) {
69
+ const c = channelNameOf(node.initializer);
70
+ if (c)
71
+ push(c.name, node, c.detail);
72
+ }
73
+ // Direct string-literal `.publish/.subscribe("channel", ...)` call sites.
74
+ if (ts.isCallExpression(node) &&
75
+ ts.isPropertyAccessExpression(node.expression) &&
76
+ PUBSUB_METHODS.has(node.expression.name.text)) {
77
+ const arg0 = node.arguments[0];
78
+ const c = arg0 ? channelNameOf(arg0) : null;
79
+ if (c)
80
+ push(c.name, node, c.detail);
81
+ }
82
+ ts.forEachChild(node, visit);
83
+ }
84
+ visit(src);
85
+ return facts;
86
+ }
87
+ /**
88
+ * setInterval poller registrations — bare (`setInterval(...)`) or off an object
89
+ * (`globalThis.setInterval(...)`). Numeric-literal interval → detail; a
90
+ * variable/expression interval → unanalyzable. (setInterval is a Node global,
91
+ * not rush-specific — but kept local to this adapter rather than shared with
92
+ * agenthub until a 3rd adapter justifies extraction.)
93
+ */
94
+ export function extractPollers(filePath, content) {
95
+ const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
96
+ const facts = [];
97
+ const isSetInterval = (expr) => (ts.isIdentifier(expr) && expr.text === "setInterval") ||
98
+ (ts.isPropertyAccessExpression(expr) && expr.name.text === "setInterval") ||
99
+ (ts.isElementAccessExpression(expr) &&
100
+ ts.isStringLiteral(expr.argumentExpression) &&
101
+ expr.argumentExpression.text === "setInterval");
102
+ function visit(node) {
103
+ if (ts.isCallExpression(node) && isSetInterval(node.expression)) {
104
+ const intervalArg = node.arguments[1];
105
+ const literalMs = intervalArg && ts.isNumericLiteral(intervalArg) ? intervalArg.text : undefined;
106
+ facts.push({
107
+ signal: "setinterval_poller",
108
+ name: `setInterval@${filePath}:${lineOf(src, node)}`,
109
+ filePath,
110
+ line: lineOf(src, node),
111
+ ...(literalMs
112
+ ? { detail: `${literalMs}ms` }
113
+ : { detail: "dynamic interval", unanalyzable: true }),
114
+ });
115
+ }
116
+ ts.forEachChild(node, visit);
117
+ }
118
+ visit(src);
119
+ return facts;
120
+ }
121
+ /** Runs every rush extractor over one file. */
122
+ export function extractFacts(filePath, content) {
123
+ return [...extractRedisChannels(filePath, content), ...extractPollers(filePath, content)];
124
+ }
125
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1,10 @@
1
+ import type { Adapter } from "../types.js";
2
+ /**
3
+ * The rush (rush-app) adapter behind the engine's Adapter interface (E2 /
4
+ * GATE-3). Proves the engine↔adapter seam generalizes to a second, differently
5
+ * shaped stack (Redis pub/sub + setInterval, no pg-boss) without touching the
6
+ * engine or the agenthub adapter. Ships no defaultInv1Config — a target's
7
+ * guarded-writer config lives in its own loopgraph.config.json.
8
+ */
9
+ export declare const rushAdapter: Adapter;
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { RUSH_ADAPTER_VERSION, extractFacts } from "./extract.js";
2
+ /**
3
+ * The rush (rush-app) adapter behind the engine's Adapter interface (E2 /
4
+ * GATE-3). Proves the engine↔adapter seam generalizes to a second, differently
5
+ * shaped stack (Redis pub/sub + setInterval, no pg-boss) without touching the
6
+ * engine or the agenthub adapter. Ships no defaultInv1Config — a target's
7
+ * guarded-writer config lives in its own loopgraph.config.json.
8
+ */
9
+ export const rushAdapter = {
10
+ name: "rush",
11
+ version: RUSH_ADAPTER_VERSION,
12
+ candidatePattern: "setInterval\\(|\\.(publish|subscribe)\\(",
13
+ extractFacts,
14
+ };
15
+ //# sourceMappingURL=index.js.map
@@ -1,5 +1,30 @@
1
1
  import type { Inv1Config } from "../validate/inv1/config.js";
2
- import type { ImplementationFact } from "./agenthub/extract.js";
2
+ /**
3
+ * The kind of implementation signal a fact represents. An OPEN string, not a
4
+ * closed union: each adapter names its own signal kinds (agenthub:
5
+ * `pg_boss_queue` / `setinterval_poller`; rush: `redis_channel` / …). The
6
+ * engine treats it opaquely — it never enumerates every adapter's kinds — so a
7
+ * new adapter adds signals without the engine changing.
8
+ */
9
+ export type SignalKind = string;
10
+ /**
11
+ * One extracted implementation signal (a "registerable" background unit the
12
+ * behavioral model is supposed to account for). Lives here on the engine↔adapter
13
+ * boundary — NOT inside any one adapter — so every adapter and the engine share
14
+ * one contract instead of importing it from a sibling adapter.
15
+ */
16
+ export interface ImplementationFact {
17
+ signal: SignalKind;
18
+ /** The queue/channel/… name, or the poller's enclosing function/file marker. */
19
+ name: string;
20
+ filePath: string;
21
+ /** 1-based line for a human reading the report — not a stable anchor. */
22
+ line: number;
23
+ /** Extra context: e.g. "env-suffixed" for a templated name, or the interval. */
24
+ detail?: string;
25
+ /** true when the fact's key part (name / interval) couldn't be read statically. */
26
+ unanalyzable?: boolean;
27
+ }
3
28
  /**
4
29
  * The engine↔adapter boundary (Proposal 006 E1 / 001 §2 三分离, Phase 5
5
30
  * generalization). The ENGINE (loader, T0, INV-1 scan primitive, query, cache,
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { runFacts } from "../adapters/agenthub/facts.js";
2
+ import { adapterNames, getAdapter } from "../adapters/registry.js";
2
3
  import { loadModel } from "../loader/load-model.js";
3
4
  import { startMcpServer } from "../mcp/server.js";
4
5
  import { runQuery } from "../query/run-query.js";
@@ -28,7 +29,7 @@ const USAGE = "usage: loopgraph <init|import|check> [dir] [--adapter name] [--re
28
29
  " loopgraph inspect <node-id> [dir] [--depth n]\n" +
29
30
  " loopgraph <impact|plan|scenario|evidence|matrix> <id> [dir]\n" +
30
31
  " loopgraph mcp [dir] # start the stdio MCP server\n" +
31
- " loopgraph facts [repo] # extract implementation facts (pg-boss queues, pollers)\n" +
32
+ " loopgraph facts [repo] [--adapter name] # extract implementation facts (default adapter: agenthub)\n" +
32
33
  " loopgraph snapshot [dir] --repo-root path [--out file] [--drift prior.json] # T2 nightly scan artifact";
33
34
  /**
34
35
  * Reads a `--flag value` string option. Returns `{ error }` when the flag
@@ -47,6 +48,27 @@ function readStringFlag(flags, name) {
47
48
  return { value: raw };
48
49
  return { value: undefined, error: `--${name} requires a value` };
49
50
  }
51
+ /**
52
+ * Resolve `--adapter <name>` for the fact-extracting commands (facts /
53
+ * reconcile / snapshot). Returns `{}` (no `adapter` key) when the flag is absent
54
+ * — the caller then falls back to the default adapter — or `{ error: true }`
55
+ * after reporting a malformed flag / unknown adapter name.
56
+ */
57
+ function resolveAdapterFlag(flags, io) {
58
+ const f = readStringFlag(flags, "adapter");
59
+ if (f.error) {
60
+ io.error(`${f.error}. ${USAGE}`);
61
+ return { error: true };
62
+ }
63
+ if (f.value === undefined)
64
+ return {}; // no --adapter → caller uses the default
65
+ const adapter = getAdapter(f.value);
66
+ if (!adapter) {
67
+ io.error(`unknown adapter "${f.value}" (available: ${adapterNames().join(", ")})`);
68
+ return { error: true };
69
+ }
70
+ return { adapter };
71
+ }
50
72
  /**
51
73
  * Core dispatch, factored out of the process entry point so tests can
52
74
  * drive it in-process (real filesystem via temp dirs, no subprocess
@@ -214,8 +236,15 @@ export async function run(argv, io) {
214
236
  case "facts": {
215
237
  // Extract implementation facts from a target repo (positionals[0] = repo
216
238
  // root, defaulting to cwd). `--no-cache` disables the B3 content cache.
239
+ // `--adapter <name>` selects the target stack's adapter (default agenthub).
217
240
  const repo = positionals[0] ?? process.cwd();
218
- const result = await runFacts(repo, flags["no-cache"] === true ? { cacheDir: null } : {});
241
+ const factsAdapter = resolveAdapterFlag(flags, io);
242
+ if ("error" in factsAdapter)
243
+ return 1;
244
+ const result = await runFacts(repo, {
245
+ ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
246
+ ...(factsAdapter.adapter ? { adapter: factsAdapter.adapter } : {}),
247
+ });
219
248
  if (!result.ran) {
220
249
  io.log(`⚠ facts extraction skipped: ${result.skippedReason}`);
221
250
  return 0;
@@ -246,8 +275,14 @@ export async function run(argv, io) {
246
275
  io.error(`reconcile needs --repo-root <repo>. ${USAGE}`);
247
276
  return 1;
248
277
  }
278
+ const reconcileAdapter = resolveAdapterFlag(flags, io);
279
+ if ("error" in reconcileAdapter)
280
+ return 1;
249
281
  const model = await loadModel(`${targetDir}/model`);
250
- const factsResult = await runFacts(repoRootFlag.value, flags["no-cache"] === true ? { cacheDir: null } : {});
282
+ const factsResult = await runFacts(repoRootFlag.value, {
283
+ ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
284
+ ...(reconcileAdapter.adapter ? { adapter: reconcileAdapter.adapter } : {}),
285
+ });
251
286
  if (!factsResult.ran) {
252
287
  io.log(`⚠ reconcile skipped: ${factsResult.skippedReason}`);
253
288
  return 0;
@@ -279,9 +314,13 @@ export async function run(argv, io) {
279
314
  return 1;
280
315
  }
281
316
  }
317
+ const snapshotAdapter = resolveAdapterFlag(flags, io);
318
+ if ("error" in snapshotAdapter)
319
+ return 1;
282
320
  const snapshot = await runSnapshot(targetDir, {
283
321
  repoRoot: repoRootFlag.value,
284
322
  ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
323
+ ...(snapshotAdapter.adapter ? { adapter: snapshotAdapter.adapter } : {}),
285
324
  });
286
325
  io.log(renderSnapshotSummary(snapshot));
287
326
  if (driftFlag.value) {
@@ -1,4 +1,4 @@
1
- import type { ImplementationFact } from "../adapters/agenthub/extract.js";
1
+ import type { ImplementationFact } from "../adapters/types.js";
2
2
  import type { ModelGraph } from "../loader/model-graph.js";
3
3
  /**
4
4
  * C2 T1 reconciliation (Proposal 006 Phase 3): which extracted implementation
@@ -81,9 +81,15 @@ export function reconcileFacts(facts, graph) {
81
81
  const unregistered = [];
82
82
  // Three DISJOINT buckets (active wins over dormant wins over nothing), so
83
83
  // `registered`, `dormantSuppressed`, and `unregistered` partition the facts.
84
- // A queue fact is active-registered by EITHER its file being anchored OR its
85
- // name being declared in some active loop's consumes_queues (producer/consumer
86
- // split: the definition file may carry no anchor while the loop is modeled).
84
+ // EVERY fact any adapter, any signal kind reconciles by FILE (the base
85
+ // mechanism): file anchored registered. The extra `consumes_queues`
86
+ // name-match is an OPT-IN, currently only for `pg_boss_queue` (agenthub's
87
+ // producer/consumer split: a queue defined in an un-anchored registry file
88
+ // whose loop is modeled elsewhere). Other signals (e.g. rush's
89
+ // `redis_channel`) are NOT ignored — they just reconcile by file only. If a
90
+ // second adapter needs the same name-match, generalize this to an
91
+ // adapter-declared "name-matchable signal kinds" set rather than hardcoding
92
+ // more kinds here (GATE-3 doesn't need it; file-level reconcile suffices).
87
93
  const isActive = (f) => active.has(f.filePath) || (f.signal === "pg_boss_queue" && consumedQueues.has(f.name));
88
94
  for (const f of facts) {
89
95
  if (isActive(f)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopgraph",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",