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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 loopgraph contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# loopgraph
|
|
2
|
+
|
|
3
|
+
模型驱动工程控制系统(Model-Driven Engineering Control)——把"系统应该怎么运转"写成机器可校验的行为模型,钉在真实代码上,每次 PR 秒级对账,防止实现与认知漂移。
|
|
4
|
+
|
|
5
|
+
## 核心思路
|
|
6
|
+
|
|
7
|
+
以结构化行为模型(Feature / Flow / Loop / Junction / Scenario(GWT) / Evidence)为规范事实源,把目标仓库的代码、测试与可观测信号当作投影与证据:
|
|
8
|
+
|
|
9
|
+
- **确定性增量 CI(永不调 LLM)**:T0 检查(schema / 引用完整性 / 图无环 / 锚点存在性)、canonical-writer 不变量扫描(纯 AST)、实现事实对账、`--diff` 增量、内容寻址共享缓存(跨 worktree)。全量亚秒级。
|
|
10
|
+
- **发现靠 LLM,防腐靠引擎**:随附 agent 提示词套件(四遍扫描:假设都是 loop → 三判据证伪 → 组合 → trace 升维),由 coding agent 离线建模;引擎负责锚点核实与漂移拦截。
|
|
11
|
+
- **MCP 查询 + file side-channel**:给 agent 提供任务相关的模型切片(impact / plan / scenario / evidence / matrix),替代全量阅读 spec。
|
|
12
|
+
|
|
13
|
+
## 快速开始
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx loopgraph init # 在目标仓库生成 model/ 骨架 + .loopgraph/agent/ 指令套件
|
|
17
|
+
npx loopgraph check . --repo-root . --strict-anchors # T0 门禁(确定性,秒级)
|
|
18
|
+
npx loopgraph reconcile . --repo-root . # 实现信号 vs 模型登记对账(advisory)
|
|
19
|
+
npx loopgraph snapshot . --repo-root . # nightly 全量快照 + 漂移报告(永不进门禁)
|
|
20
|
+
npx loopgraph impact <loop-id> . # 改这里会牵动什么
|
|
21
|
+
npx loopgraph mcp . # stdio MCP server(给 agent 用)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`init` 产出的 `.loopgraph/agent/` 含三个指令文件,由你仓库里的 coding agent 执行:发现建模(`loop-discovery.md`)、给 PR 模板加 Loop 声明栏、按本仓库 CI 约定编写 advisory / nightly 两个 workflow(均不阻塞 PR)。
|
|
25
|
+
|
|
26
|
+
## 模型长什么样
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
# model/loops/*.yaml — 一个后台控制循环(示例为虚构系统)
|
|
30
|
+
- id: L1
|
|
31
|
+
kind: loop
|
|
32
|
+
title: Order 状态机
|
|
33
|
+
boundary: "pending→processing→shipped/cancelled;cancelled 为终态"
|
|
34
|
+
owner: packages/orders
|
|
35
|
+
anchors: ["packages/orders/src/order-service.ts#OrderService"] # 钉在真实代码符号上
|
|
36
|
+
consumes_queues: ["order:process"] # 按名对账生产/消费分离
|
|
37
|
+
scenarios: [GWT-A1-001] # GWT 场景,verified_by 指向真实测试
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
junction(跨循环风险点)、flow(端到端链路)、debt(死状态机基线,只减不增)同理——全部 YAML,file-per-node,`loopgraph check` 机器校验。
|
|
41
|
+
|
|
42
|
+
## 设计红线
|
|
43
|
+
|
|
44
|
+
- 门禁检查**零 LLM、零网络、零代码执行**——快、便宜、可复现、可解释。
|
|
45
|
+
- 缓存零正确性依赖(cold == warm 逐字节一致)。
|
|
46
|
+
- 快照/漂移报告(T2)永不进 PR 门禁。
|
|
47
|
+
- 模型新增须过锚点核实;发现层(LLM)只产草案,落库须经维护者核实。
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
见 package.json。
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentHub adapter — implementation-fact extraction (Proposal 006 B1 / 001 §2
|
|
3
|
+
* 三分离's "适配器" pillar). PURE functions of `(filePath, content)` — an
|
|
4
|
+
* in-memory `ts.createSourceFile` parse, no I/O — so the results are cacheable
|
|
5
|
+
* on the same `hash(adapter_version, blob_oid)` terms as A6's INV-1 scan.
|
|
6
|
+
*
|
|
7
|
+
* These surface the "registerable" implementation units (pg-boss queues,
|
|
8
|
+
* setInterval pollers) that the behavioral model is supposed to account for.
|
|
9
|
+
* Listing them is the `extract` stage of the pipeline (001 §3); comparing them
|
|
10
|
+
* against the model to flag UNREGISTERED units is a T1 check (Milestone C, out
|
|
11
|
+
* of B1's scope) — this layer only produces the facts.
|
|
12
|
+
*
|
|
13
|
+
* Scope note (B1 subset): two of the six planned signals are implemented here
|
|
14
|
+
* (pg-boss queue, setInterval poller — the clearest background-loop signals).
|
|
15
|
+
* The Next.js route / drizzle status-column / vitest-E2E / metric-event
|
|
16
|
+
* extractors are deferred; each is an independent pure extractor that slots in
|
|
17
|
+
* beside these (the file domain is non-overlapping, per 006 B1).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Bumped whenever an extractor's LOGIC changes — it's part of the B3 cache key,
|
|
21
|
+
* so a bump invalidates every stale cached result at once (a file whose bytes
|
|
22
|
+
* didn't change still needs re-extraction if the extractor did).
|
|
23
|
+
*/
|
|
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
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* pg-boss queue names. In this target stack the authoritative names live in
|
|
40
|
+
* `const *_QUEUE = <string | template>` declarations (e.g.
|
|
41
|
+
* `` FOO_QUEUE = `foo:process${QUEUE_SUFFIX}` ``), referenced by identifier at
|
|
42
|
+
* the `boss.send(FOO_QUEUE, ...)` call sites — a literal AT the call site is the
|
|
43
|
+
* exception (and is often just a JSDoc-diagram string, which the AST parse
|
|
44
|
+
* ignores). So the primary signal is the const declaration; the `*QUEUE*`
|
|
45
|
+
* naming convention is adapter knowledge, which is exactly what an adapter is
|
|
46
|
+
* allowed to encode. Direct string-literal `.work/.send/.schedule` call sites
|
|
47
|
+
* are also captured. Deduped by queue name.
|
|
48
|
+
*/
|
|
49
|
+
export declare function extractQueues(filePath: string, content: string): ImplementationFact[];
|
|
50
|
+
/**
|
|
51
|
+
* setInterval poller registrations: each `setInterval(fn, <interval>)` call
|
|
52
|
+
* site, whether called bare (`setInterval(...)`) or off an object
|
|
53
|
+
* (`globalThis.setInterval(...)`, `window.setInterval(...)`). The interval, if a
|
|
54
|
+
* numeric literal, is reported as detail; a variable/expression interval is
|
|
55
|
+
* marked unanalyzable (the poller exists either way — the cadence just isn't
|
|
56
|
+
* statically known). Residual: an aliased import (`import { setInterval as poll
|
|
57
|
+
* }` called as `poll(...)`) is not caught (needs import tracking) — a documented
|
|
58
|
+
* limitation; this target uses the bare Node global.
|
|
59
|
+
*/
|
|
60
|
+
export declare function extractPollers(filePath: string, content: string): ImplementationFact[];
|
|
61
|
+
/** Runs every implemented extractor over one file. */
|
|
62
|
+
export declare function extractFacts(filePath: string, content: string): ImplementationFact[];
|
|
63
|
+
//# sourceMappingURL=extract.d.ts.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* AgentHub adapter — implementation-fact extraction (Proposal 006 B1 / 001 §2
|
|
4
|
+
* 三分离's "适配器" pillar). PURE functions of `(filePath, content)` — an
|
|
5
|
+
* in-memory `ts.createSourceFile` parse, no I/O — so the results are cacheable
|
|
6
|
+
* on the same `hash(adapter_version, blob_oid)` terms as A6's INV-1 scan.
|
|
7
|
+
*
|
|
8
|
+
* These surface the "registerable" implementation units (pg-boss queues,
|
|
9
|
+
* setInterval pollers) that the behavioral model is supposed to account for.
|
|
10
|
+
* Listing them is the `extract` stage of the pipeline (001 §3); comparing them
|
|
11
|
+
* against the model to flag UNREGISTERED units is a T1 check (Milestone C, out
|
|
12
|
+
* of B1's scope) — this layer only produces the facts.
|
|
13
|
+
*
|
|
14
|
+
* Scope note (B1 subset): two of the six planned signals are implemented here
|
|
15
|
+
* (pg-boss queue, setInterval poller — the clearest background-loop signals).
|
|
16
|
+
* The Next.js route / drizzle status-column / vitest-E2E / metric-event
|
|
17
|
+
* extractors are deferred; each is an independent pure extractor that slots in
|
|
18
|
+
* beside these (the file domain is non-overlapping, per 006 B1).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Bumped whenever an extractor's LOGIC changes — it's part of the B3 cache key,
|
|
22
|
+
* so a bump invalidates every stale cached result at once (a file whose bytes
|
|
23
|
+
* didn't change still needs re-extraction if the extractor did).
|
|
24
|
+
*/
|
|
25
|
+
export const ADAPTER_VERSION = "agenthub-facts-1";
|
|
26
|
+
function lineOf(src, node) {
|
|
27
|
+
return src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1;
|
|
28
|
+
}
|
|
29
|
+
/** The static queue name of a string/template expression, or null if not statically known. */
|
|
30
|
+
function queueNameOf(expr) {
|
|
31
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr))
|
|
32
|
+
return { name: expr.text };
|
|
33
|
+
if (ts.isTemplateExpression(expr)) {
|
|
34
|
+
// Join every STATIC part (head + each span's trailing literal) with "…" for
|
|
35
|
+
// the dynamic `${}` gaps: `foo:process${SUFFIX}` → "foo:process";
|
|
36
|
+
// `${PREFIX}foo:process` → "foo:process"; `${A}${B}` → "" (fully dynamic).
|
|
37
|
+
const staticParts = [expr.head.text, ...expr.templateSpans.map((s) => s.literal.text)].filter(Boolean);
|
|
38
|
+
if (staticParts.length === 0)
|
|
39
|
+
return { name: "<dynamic>", detail: "env-templated" };
|
|
40
|
+
return { name: staticParts.join("…"), detail: "env-templated" };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* pg-boss queue names. In this target stack the authoritative names live in
|
|
46
|
+
* `const *_QUEUE = <string | template>` declarations (e.g.
|
|
47
|
+
* `` FOO_QUEUE = `foo:process${QUEUE_SUFFIX}` ``), referenced by identifier at
|
|
48
|
+
* the `boss.send(FOO_QUEUE, ...)` call sites — a literal AT the call site is the
|
|
49
|
+
* exception (and is often just a JSDoc-diagram string, which the AST parse
|
|
50
|
+
* ignores). So the primary signal is the const declaration; the `*QUEUE*`
|
|
51
|
+
* naming convention is adapter knowledge, which is exactly what an adapter is
|
|
52
|
+
* allowed to encode. Direct string-literal `.work/.send/.schedule` call sites
|
|
53
|
+
* are also captured. Deduped by queue name.
|
|
54
|
+
*/
|
|
55
|
+
export function extractQueues(filePath, content) {
|
|
56
|
+
const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
57
|
+
const facts = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const QUEUE_METHODS = new Set(["work", "send", "schedule"]);
|
|
60
|
+
const push = (name, node, detail) => {
|
|
61
|
+
if (seen.has(name))
|
|
62
|
+
return; // one fact per distinct queue name
|
|
63
|
+
seen.add(name);
|
|
64
|
+
facts.push({
|
|
65
|
+
signal: "pg_boss_queue",
|
|
66
|
+
name,
|
|
67
|
+
filePath,
|
|
68
|
+
line: lineOf(src, node),
|
|
69
|
+
...(detail ? { detail } : {}),
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
function visit(node) {
|
|
73
|
+
// `const *QUEUE* = <string | template>` — the authoritative queue-name decl.
|
|
74
|
+
// The target's convention is SCREAMING_SNAKE with QUEUE as a token, mid-name
|
|
75
|
+
// or suffix (e.g. FOO_QUEUE, BAR_QUEUE_WEB, BAZ_QUEUE_BASE), so match a
|
|
76
|
+
// CASE-SENSITIVE uppercase "QUEUE": that catches every real one while
|
|
77
|
+
// excluding the camelCase/lowercase false-matches (`queueLength`,
|
|
78
|
+
// `myQueueHandler`). Direct literal call sites below catch anything not
|
|
79
|
+
// declared via this convention.
|
|
80
|
+
if (ts.isVariableDeclaration(node) &&
|
|
81
|
+
ts.isIdentifier(node.name) &&
|
|
82
|
+
node.name.text.includes("QUEUE") &&
|
|
83
|
+
node.initializer) {
|
|
84
|
+
const q = queueNameOf(node.initializer);
|
|
85
|
+
if (q)
|
|
86
|
+
push(q.name, node, q.detail);
|
|
87
|
+
}
|
|
88
|
+
// Direct string-literal `.work/.send/.schedule("queue", ...)` registrations.
|
|
89
|
+
if (ts.isCallExpression(node) &&
|
|
90
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
91
|
+
QUEUE_METHODS.has(node.expression.name.text)) {
|
|
92
|
+
const arg0 = node.arguments[0];
|
|
93
|
+
const q = arg0 ? queueNameOf(arg0) : null;
|
|
94
|
+
if (q)
|
|
95
|
+
push(q.name, node, q.detail);
|
|
96
|
+
}
|
|
97
|
+
ts.forEachChild(node, visit);
|
|
98
|
+
}
|
|
99
|
+
visit(src);
|
|
100
|
+
return facts;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* setInterval poller registrations: each `setInterval(fn, <interval>)` call
|
|
104
|
+
* site, whether called bare (`setInterval(...)`) or off an object
|
|
105
|
+
* (`globalThis.setInterval(...)`, `window.setInterval(...)`). The interval, if a
|
|
106
|
+
* numeric literal, is reported as detail; a variable/expression interval is
|
|
107
|
+
* marked unanalyzable (the poller exists either way — the cadence just isn't
|
|
108
|
+
* statically known). Residual: an aliased import (`import { setInterval as poll
|
|
109
|
+
* }` called as `poll(...)`) is not caught (needs import tracking) — a documented
|
|
110
|
+
* limitation; this target uses the bare Node global.
|
|
111
|
+
*/
|
|
112
|
+
export function extractPollers(filePath, content) {
|
|
113
|
+
const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
114
|
+
const facts = [];
|
|
115
|
+
const isSetInterval = (expr) => (ts.isIdentifier(expr) && expr.text === "setInterval") ||
|
|
116
|
+
(ts.isPropertyAccessExpression(expr) && expr.name.text === "setInterval") ||
|
|
117
|
+
// globalThis["setInterval"](...) — element access with a string key
|
|
118
|
+
(ts.isElementAccessExpression(expr) &&
|
|
119
|
+
ts.isStringLiteral(expr.argumentExpression) &&
|
|
120
|
+
expr.argumentExpression.text === "setInterval");
|
|
121
|
+
function visit(node) {
|
|
122
|
+
if (ts.isCallExpression(node) && isSetInterval(node.expression)) {
|
|
123
|
+
const intervalArg = node.arguments[1];
|
|
124
|
+
const literalMs = intervalArg && ts.isNumericLiteral(intervalArg) ? intervalArg.text : undefined;
|
|
125
|
+
facts.push({
|
|
126
|
+
signal: "setinterval_poller",
|
|
127
|
+
name: `setInterval@${filePath}:${lineOf(src, node)}`,
|
|
128
|
+
filePath,
|
|
129
|
+
line: lineOf(src, node),
|
|
130
|
+
...(literalMs
|
|
131
|
+
? { detail: `${literalMs}ms` }
|
|
132
|
+
: { detail: "dynamic interval", unanalyzable: true }),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
ts.forEachChild(node, visit);
|
|
136
|
+
}
|
|
137
|
+
visit(src);
|
|
138
|
+
return facts;
|
|
139
|
+
}
|
|
140
|
+
/** Runs every implemented extractor over one file. */
|
|
141
|
+
export function extractFacts(filePath, content) {
|
|
142
|
+
return [...extractQueues(filePath, content), ...extractPollers(filePath, content)];
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=extract.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Adapter } from "../types.js";
|
|
2
|
+
import type { ImplementationFact } from "./extract.js";
|
|
3
|
+
export interface FactsOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Machine-level content cache dir (B3). Defaults to `~/.cache/loopgraph`;
|
|
6
|
+
* pass `null` to disable (the zero-cache-dependency path CI can take).
|
|
7
|
+
*/
|
|
8
|
+
cacheDir?: string | null;
|
|
9
|
+
/** The adapter supplying the candidate pattern + extractors (E1). Default: agenthub. */
|
|
10
|
+
adapter?: Adapter;
|
|
11
|
+
}
|
|
12
|
+
export interface FactsResult {
|
|
13
|
+
/** false when the scan couldn't run (no git checkout) — a loud skip, not a silent empty result. */
|
|
14
|
+
ran: boolean;
|
|
15
|
+
skippedReason?: string;
|
|
16
|
+
facts: ImplementationFact[];
|
|
17
|
+
candidateFiles: number;
|
|
18
|
+
filesScanned: number;
|
|
19
|
+
timingMs: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Extracts implementation facts from the target repo via the chosen adapter
|
|
23
|
+
* (E1: the runner is engine-generic; agenthub is the default adapter). Same
|
|
24
|
+
* git-grep-then-pure-AST posture as A6's INV-1 scan.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runFacts(repoRoot: string, options?: FactsOptions): Promise<FactsResult>;
|
|
27
|
+
//# sourceMappingURL=facts.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { cacheKey, contentHash, defaultCacheDir, pruneCache, withCache, } from "../../cache/content-cache.js";
|
|
6
|
+
import { getAdapter } from "../registry.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/** LRU cap for the machine-level cache (001 §5). */
|
|
9
|
+
const CACHE_MAX_ENTRIES = 50_000;
|
|
10
|
+
/**
|
|
11
|
+
* Candidate pre-filter for the fact extractors (same posture as A6's INV-1
|
|
12
|
+
* scan): `git grep -l` narrows to files that mention a pg-boss method or
|
|
13
|
+
* setInterval, then the pure AST extractors confirm. Over-matching only costs a
|
|
14
|
+
* cheap extra parse; under-matching would be a silent miss, so the pattern is
|
|
15
|
+
* broad. Returns undefined when the target is not a usable git checkout.
|
|
16
|
+
*/
|
|
17
|
+
async function gitGrepCandidates(repoRoot, pattern) {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await execFileAsync("git", ["grep", "-lE", pattern, "--", "*.ts", ":!*.test.ts", ":!*.spec.ts"], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 });
|
|
20
|
+
return (stdout
|
|
21
|
+
.split("\n")
|
|
22
|
+
// strip git's optional surrounding quotes on paths with special chars
|
|
23
|
+
.map((l) => l.trim().replace(/^"(.*)"$/, "$1"))
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
// Belt-and-suspenders over the git pathspec exclude: also drop test/spec
|
|
26
|
+
// files at the Node layer, so the result is right regardless of git
|
|
27
|
+
// version / pathspec behavior.
|
|
28
|
+
.filter((f) => !/\.(test|spec)\.ts$/.test(f)));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === 1)
|
|
32
|
+
return []; // no matches
|
|
33
|
+
return undefined; // not a git repo / no git
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Extracts implementation facts from the target repo via the chosen adapter
|
|
38
|
+
* (E1: the runner is engine-generic; agenthub is the default adapter). Same
|
|
39
|
+
* git-grep-then-pure-AST posture as A6's INV-1 scan.
|
|
40
|
+
*/
|
|
41
|
+
export async function runFacts(repoRoot, options = {}) {
|
|
42
|
+
const start = performance.now();
|
|
43
|
+
// `null` disables; `undefined` (default) uses the machine cache dir.
|
|
44
|
+
const cacheDir = options.cacheDir === null ? undefined : (options.cacheDir ?? defaultCacheDir());
|
|
45
|
+
// getAdapter("agenthub") is registered, so the `!` fallback is unreachable — guard, not assertion.
|
|
46
|
+
const adapter = options.adapter ?? getAdapter("agenthub");
|
|
47
|
+
if (!adapter)
|
|
48
|
+
throw new Error("no adapter available (agenthub not registered)");
|
|
49
|
+
const candidates = await gitGrepCandidates(repoRoot, adapter.candidatePattern);
|
|
50
|
+
if (candidates === undefined) {
|
|
51
|
+
return {
|
|
52
|
+
ran: false,
|
|
53
|
+
skippedReason: "fact extraction needs a git checkout (git grep unavailable at repo-root)",
|
|
54
|
+
facts: [],
|
|
55
|
+
candidateFiles: 0,
|
|
56
|
+
filesScanned: 0,
|
|
57
|
+
timingMs: performance.now() - start,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const facts = [];
|
|
61
|
+
let filesScanned = 0;
|
|
62
|
+
for (const rel of candidates) {
|
|
63
|
+
const content = await readFile(join(repoRoot, rel), "utf8").catch(() => undefined);
|
|
64
|
+
if (content === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
filesScanned++;
|
|
67
|
+
// Key by (adapter version, repo-relative path, content hash): `rel` is the
|
|
68
|
+
// same in every worktree of the repo, so identical content shares one entry
|
|
69
|
+
// cross-worktree; the path is still part of the key so two files with the
|
|
70
|
+
// same bytes at different paths don't collide.
|
|
71
|
+
const key = cacheKey([adapter.version, rel, contentHash(content)]);
|
|
72
|
+
const fileFacts = await withCache(cacheDir, key, () => adapter.extractFacts(rel, content));
|
|
73
|
+
facts.push(...fileFacts);
|
|
74
|
+
}
|
|
75
|
+
if (cacheDir)
|
|
76
|
+
await pruneCache(cacheDir, CACHE_MAX_ENTRIES);
|
|
77
|
+
// stable order: signal, then name, then path
|
|
78
|
+
facts.sort((a, b) => a.signal.localeCompare(b.signal) ||
|
|
79
|
+
a.name.localeCompare(b.name) ||
|
|
80
|
+
a.filePath.localeCompare(b.filePath));
|
|
81
|
+
return {
|
|
82
|
+
ran: true,
|
|
83
|
+
facts,
|
|
84
|
+
candidateFiles: candidates.length,
|
|
85
|
+
filesScanned,
|
|
86
|
+
timingMs: performance.now() - start,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=facts.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Adapter } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The agenthub adapter behind the engine's Adapter interface (E1). All
|
|
4
|
+
* target-stack knowledge lives here; the engine (runner, cache, INV-1
|
|
5
|
+
* primitive) stays generic.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately ships NO defaultInv1Config: the INV-1 guarded-table config
|
|
8
|
+
* (table names, writer allowlists) is target-repo knowledge and lives in the
|
|
9
|
+
* target's own loopgraph.config.json — embedding it here would compile
|
|
10
|
+
* target internals into the published package.
|
|
11
|
+
*/
|
|
12
|
+
export declare const agenthubAdapter: Adapter;
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ADAPTER_VERSION, extractFacts } from "./extract.js";
|
|
2
|
+
/**
|
|
3
|
+
* The agenthub adapter behind the engine's Adapter interface (E1). All
|
|
4
|
+
* target-stack knowledge lives here; the engine (runner, cache, INV-1
|
|
5
|
+
* primitive) stays generic.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately ships NO defaultInv1Config: the INV-1 guarded-table config
|
|
8
|
+
* (table names, writer allowlists) is target-repo knowledge and lives in the
|
|
9
|
+
* target's own loopgraph.config.json — embedding it here would compile
|
|
10
|
+
* target internals into the published package.
|
|
11
|
+
*/
|
|
12
|
+
export const agenthubAdapter = {
|
|
13
|
+
name: "agenthub",
|
|
14
|
+
version: ADAPTER_VERSION,
|
|
15
|
+
candidatePattern: "setInterval\\(|\\.(work|send|schedule)\\(",
|
|
16
|
+
extractFacts,
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { agenthubAdapter } from "./agenthub/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The adapter registry (E1): the single place the engine resolves an adapter by
|
|
4
|
+
* 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.
|
|
7
|
+
*/
|
|
8
|
+
const ADAPTERS = {
|
|
9
|
+
[agenthubAdapter.name]: agenthubAdapter,
|
|
10
|
+
};
|
|
11
|
+
export function getAdapter(name) {
|
|
12
|
+
return ADAPTERS[name];
|
|
13
|
+
}
|
|
14
|
+
export function adapterNames() {
|
|
15
|
+
return Object.keys(ADAPTERS);
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Inv1Config } from "../validate/inv1/config.js";
|
|
2
|
+
import type { ImplementationFact } from "./agenthub/extract.js";
|
|
3
|
+
/**
|
|
4
|
+
* The engine↔adapter boundary (Proposal 006 E1 / 001 §2 三分离, Phase 5
|
|
5
|
+
* generalization). The ENGINE (loader, T0, INV-1 scan primitive, query, cache,
|
|
6
|
+
* `--diff`, MCP) carries zero target-repo knowledge; an ADAPTER supplies the
|
|
7
|
+
* target-stack-specific facts: how to read this codebase's implementation
|
|
8
|
+
* signals, its version (part of the cache key), and its default INV-1 config.
|
|
9
|
+
*
|
|
10
|
+
* Formalizing this as an interface is the Phase 5 "通用化" — but the 001 red
|
|
11
|
+
* line holds: generalization must not degrade AgentHub analysis quality or CI
|
|
12
|
+
* speed. The regression test (test/adapter.test.ts) enforces that: the agenthub
|
|
13
|
+
* adapter routed through this interface produces BYTE-IDENTICAL facts to the
|
|
14
|
+
* direct extractor. A second adapter would slot in beside agenthub without the
|
|
15
|
+
* engine changing.
|
|
16
|
+
*/
|
|
17
|
+
export interface Adapter {
|
|
18
|
+
/** Stable adapter id, used to look it up and (with `version`) to key the cache. */
|
|
19
|
+
name: string;
|
|
20
|
+
/**
|
|
21
|
+
* Bumped when extractor LOGIC changes — part of the B3 cache key so a bump
|
|
22
|
+
* invalidates stale results even when file bytes are unchanged.
|
|
23
|
+
*/
|
|
24
|
+
version: string;
|
|
25
|
+
/**
|
|
26
|
+
* `git grep -E` pattern selecting candidate files worth parsing (the cheap
|
|
27
|
+
* pre-filter before the pure extractor confirms). Adapter-specific because it
|
|
28
|
+
* encodes which call shapes this stack's signals appear in.
|
|
29
|
+
*/
|
|
30
|
+
candidatePattern: string;
|
|
31
|
+
/** Extract implementation facts from one source file. PURE (no I/O). */
|
|
32
|
+
extractFacts(filePath: string, content: string): ImplementationFact[];
|
|
33
|
+
/**
|
|
34
|
+
* The default INV-1 guarded-column config for this target, if the adapter
|
|
35
|
+
* ships one. A repo-level `loopgraph.config.json` still overrides it; this is
|
|
36
|
+
* the fallback so a target doesn't have to re-declare what the adapter knows.
|
|
37
|
+
*/
|
|
38
|
+
defaultInv1Config?: Inv1Config;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-level content-addressed cache (Proposal 006 B3 / 001 §5) for the pure
|
|
3
|
+
* per-file extractor/scanner results (A6 INV-1 scan, B1 fact extraction).
|
|
4
|
+
*
|
|
5
|
+
* Key = sha256 over caller-supplied parts — for the fact extractor,
|
|
6
|
+
* (adapter_version, repo-relative path, content_hash). Every part is
|
|
7
|
+
* worktree-INDEPENDENT: the content hash is by definition, and the
|
|
8
|
+
* repo-relative path is the same in every worktree of the repo. So identical
|
|
9
|
+
* bytes at the same in-repo path share ONE entry across the user's many
|
|
10
|
+
* worktrees (the cross-worktree sharing 001 §5 calls for), while the path stays
|
|
11
|
+
* in the key so two files with the same bytes at DIFFERENT paths don't collide
|
|
12
|
+
* (the cached value embeds its path). Content-addressing also makes writes
|
|
13
|
+
* idempotent: a given key's value is deterministic, so a concurrent write of
|
|
14
|
+
* the same key is harmless (both write identical bytes; temp+rename just makes
|
|
15
|
+
* each atomic).
|
|
16
|
+
*
|
|
17
|
+
* Correctness has ZERO cache dependency (001 §6 red line): the value is exactly
|
|
18
|
+
* `compute()` — a cold run (empty cache) and a warm run are byte-identical, and
|
|
19
|
+
* a missing/corrupt/disabled cache just recomputes. `cacheDir: undefined`
|
|
20
|
+
* disables it entirely (the path CI can take to prove independence).
|
|
21
|
+
*
|
|
22
|
+
* Not modeled (documented deferral): the git-index fast-path for reading a
|
|
23
|
+
* clean tracked file's blob OID without re-hashing. Hashing the content
|
|
24
|
+
* directly is always correct and worktree-identical; the index lookup is only a
|
|
25
|
+
* micro-optimization, so B3 ships the always-correct content-hash path.
|
|
26
|
+
*/
|
|
27
|
+
export declare function defaultCacheDir(): string;
|
|
28
|
+
/** sha256 of a file's content — the worktree-independent part of the cache key. */
|
|
29
|
+
export declare function contentHash(content: string): string;
|
|
30
|
+
/** The cache key: a hash over the ordered key parts (adapter version, content hash, config hash). */
|
|
31
|
+
export declare function cacheKey(parts: string[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Read-or-compute. On a hit, returns the parsed cached value; on a miss (or
|
|
34
|
+
* disabled/corrupt cache), computes, atomically writes (temp + rename), and
|
|
35
|
+
* returns. `compute` must be a PURE function of the same inputs the key was
|
|
36
|
+
* derived from — that's what makes the cache sound. `compute` may be sync or
|
|
37
|
+
* async (`await` handles both).
|
|
38
|
+
*/
|
|
39
|
+
export declare function withCache<T>(cacheDir: string | undefined, key: string, compute: () => T | Promise<T>): Promise<T>;
|
|
40
|
+
/**
|
|
41
|
+
* Bounds the cache to `maxEntries` most-recently-modified entries (the LRU cap
|
|
42
|
+
* from 001 §5). Cheap and best-effort — called once per run, not per file — so
|
|
43
|
+
* an occasional over-count between prunes is fine.
|
|
44
|
+
*/
|
|
45
|
+
export declare function pruneCache(cacheDir: string, maxEntries: number): Promise<number>;
|
|
46
|
+
//# sourceMappingURL=content-cache.d.ts.map
|