loopgraph 0.1.0 → 0.2.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.
@@ -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,
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
@@ -214,8 +215,25 @@ export async function run(argv, io) {
214
215
  case "facts": {
215
216
  // Extract implementation facts from a target repo (positionals[0] = repo
216
217
  // root, defaulting to cwd). `--no-cache` disables the B3 content cache.
218
+ // `--adapter <name>` selects the target stack's adapter (default agenthub).
217
219
  const repo = positionals[0] ?? process.cwd();
218
- const result = await runFacts(repo, flags["no-cache"] === true ? { cacheDir: null } : {});
220
+ const adapterFlag = readStringFlag(flags, "adapter");
221
+ if (adapterFlag.error) {
222
+ io.error(`${adapterFlag.error}. ${USAGE}`);
223
+ 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
+ const result = await runFacts(repo, {
234
+ ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
235
+ ...(adapter ? { adapter } : {}),
236
+ });
219
237
  if (!result.ran) {
220
238
  io.log(`⚠ facts extraction skipped: ${result.skippedReason}`);
221
239
  return 0;
@@ -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.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",