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,172 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
/** True if `filePath` is under (or equal to) one of the prefixes, matched at a path boundary. */
|
|
3
|
+
function underAnyPrefix(filePath, prefixes) {
|
|
4
|
+
const norm = filePath.replace(/\\/g, "/");
|
|
5
|
+
return prefixes.some((p) => {
|
|
6
|
+
const pfx = p.replace(/\\/g, "/").replace(/\/$/, "");
|
|
7
|
+
return norm === pfx || norm.startsWith(`${pfx}/`);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** Columns named by an object literal's keys, or "opaque" for a spread / computed key / non-object. */
|
|
11
|
+
function columnsOfObjectLiteral(expr) {
|
|
12
|
+
if (!expr || !ts.isObjectLiteralExpression(expr))
|
|
13
|
+
return "opaque"; // .set(variable) etc.
|
|
14
|
+
const cols = [];
|
|
15
|
+
for (const prop of expr.properties) {
|
|
16
|
+
if (ts.isSpreadAssignment(prop))
|
|
17
|
+
return "opaque"; // {...base} may hide a guarded column
|
|
18
|
+
if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
|
|
19
|
+
const name = prop.name;
|
|
20
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name))
|
|
21
|
+
cols.push(name.text);
|
|
22
|
+
else
|
|
23
|
+
return "opaque"; // computed property name — can't resolve statically
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
return "opaque"; // method / accessor — treat conservatively
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return cols;
|
|
30
|
+
}
|
|
31
|
+
/** The `set:` value inside an `onConflictDoUpdate({ target, set })` argument. */
|
|
32
|
+
function columnsOfOnConflictSet(arg) {
|
|
33
|
+
if (!arg || !ts.isObjectLiteralExpression(arg))
|
|
34
|
+
return "opaque";
|
|
35
|
+
for (const prop of arg.properties) {
|
|
36
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === "set") {
|
|
37
|
+
return columnsOfObjectLiteral(prop.initializer);
|
|
38
|
+
}
|
|
39
|
+
// A shorthand `{ set }` means `set` is a variable reference — its columns
|
|
40
|
+
// aren't visible, which is exactly "opaque". Fall through to the opaque
|
|
41
|
+
// return below rather than special-casing to the same result.
|
|
42
|
+
}
|
|
43
|
+
return "opaque"; // no static `set: {…}` property found (absent, or shorthand/variable)
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Descends a `db.insert(T).values(...).onConflictDoUpdate(...)` / `db.update(T).set(...)`
|
|
47
|
+
* receiver chain to the `insert`/`update` call and returns its first argument
|
|
48
|
+
* (the table expression), or undefined if the chain has no such call.
|
|
49
|
+
*/
|
|
50
|
+
function tableArgInChain(node) {
|
|
51
|
+
let cur = node;
|
|
52
|
+
while (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
|
|
53
|
+
const method = cur.expression.name.text;
|
|
54
|
+
if (method === "insert" || method === "update")
|
|
55
|
+
return cur.arguments[0];
|
|
56
|
+
cur = cur.expression.expression;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
export function scanFileForGuardedWrites(filePath, content, config) {
|
|
61
|
+
const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
62
|
+
const points = [];
|
|
63
|
+
const anyGuardedAllowlist = Object.values(config.guardedTables).flatMap((t) => t.allowlist);
|
|
64
|
+
const inAnyAllowlist = underAnyPrefix(filePath, anyGuardedAllowlist);
|
|
65
|
+
const inExceptions = underAnyPrefix(filePath, config.unanalyzableExceptions);
|
|
66
|
+
const configResolve = (ident) => {
|
|
67
|
+
if (config.guardedTables[ident])
|
|
68
|
+
return ident;
|
|
69
|
+
const canon = config.aliases[ident];
|
|
70
|
+
return canon && config.guardedTables[canon] ? canon : undefined;
|
|
71
|
+
};
|
|
72
|
+
// Pre-scan file-local aliases: `const X = runs` (initializer is a guarded
|
|
73
|
+
// table identifier). Bounded, pure; closes the local-alias gap without
|
|
74
|
+
// flagging every non-guarded update as unanalyzable.
|
|
75
|
+
const localAliases = new Map();
|
|
76
|
+
(function collectAliases(node) {
|
|
77
|
+
if (ts.isVariableDeclaration(node) &&
|
|
78
|
+
ts.isIdentifier(node.name) &&
|
|
79
|
+
node.initializer &&
|
|
80
|
+
ts.isIdentifier(node.initializer)) {
|
|
81
|
+
const resolved = configResolve(node.initializer.text);
|
|
82
|
+
if (resolved)
|
|
83
|
+
localAliases.set(node.name.text, resolved);
|
|
84
|
+
}
|
|
85
|
+
ts.forEachChild(node, collectAliases);
|
|
86
|
+
})(src);
|
|
87
|
+
const resolveTable = (ident) => configResolve(ident) ?? localAliases.get(ident);
|
|
88
|
+
const lineOf = (node) => src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1;
|
|
89
|
+
const snippetOf = (node) => content.slice(node.getStart(src), node.getEnd()).replace(/\s+/g, " ").trim().slice(0, 120);
|
|
90
|
+
function classify(tableArg, columns, at, form) {
|
|
91
|
+
// Dynamic table (non-identifier): could be a guarded table via a variable.
|
|
92
|
+
if (!tableArg || !ts.isIdentifier(tableArg)) {
|
|
93
|
+
if (inExceptions || inAnyAllowlist)
|
|
94
|
+
return; // db-generic primitive, or a canonical-writer file → not surfaced
|
|
95
|
+
points.push({
|
|
96
|
+
filePath,
|
|
97
|
+
line: lineOf(at),
|
|
98
|
+
table: null,
|
|
99
|
+
columns: "opaque",
|
|
100
|
+
verdict: "unanalyzable",
|
|
101
|
+
reason: `dynamic table expression in ${form} outside every writer allowlist — cannot confirm it is not a guarded write`,
|
|
102
|
+
snippet: snippetOf(at),
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const table = resolveTable(tableArg.text);
|
|
107
|
+
if (!table)
|
|
108
|
+
return; // a bare identifier that is not a guarded table → a write to some other table, ignore
|
|
109
|
+
const guarded = config.guardedTables[table];
|
|
110
|
+
if (!guarded)
|
|
111
|
+
return; // unreachable (resolveTable guarantees it), guard not assertion
|
|
112
|
+
// Two orthogonal facts, kept as distinct booleans for clarity: whether the
|
|
113
|
+
// column set is knowable at all, and (if it is) whether it touches a
|
|
114
|
+
// guarded column.
|
|
115
|
+
const isOpaque = columns === "opaque";
|
|
116
|
+
const guardedCols = isOpaque ? [] : columns.filter((c) => guarded.columns.includes(c));
|
|
117
|
+
if (!isOpaque && guardedCols.length === 0)
|
|
118
|
+
return; // writes only non-guarded columns → not an INV-1 concern
|
|
119
|
+
const allowed = underAnyPrefix(filePath, guarded.allowlist);
|
|
120
|
+
const base = { filePath, line: lineOf(at), table, columns, snippet: snippetOf(at) };
|
|
121
|
+
if (allowed) {
|
|
122
|
+
points.push({
|
|
123
|
+
...base,
|
|
124
|
+
verdict: "allowed",
|
|
125
|
+
reason: isOpaque
|
|
126
|
+
? `opaque ${form} to guarded table ${table}, but writer file is in its allowlist`
|
|
127
|
+
: `guarded column write to ${table} from an allowlisted canonical writer`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else if (!isOpaque) {
|
|
131
|
+
points.push({
|
|
132
|
+
...base,
|
|
133
|
+
verdict: "violation",
|
|
134
|
+
reason: `writes guarded column(s) [${guardedCols.join(", ")}] of ${table} from outside its writer allowlist (${guarded.allowlist.join(", ")})`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
points.push({
|
|
139
|
+
...base,
|
|
140
|
+
verdict: "unanalyzable",
|
|
141
|
+
reason: `opaque ${form} to guarded table ${table} outside its writer allowlist — may write a guarded column`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function visit(node) {
|
|
146
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
147
|
+
const method = node.expression.name.text;
|
|
148
|
+
// `<x>.update(TABLE).set(SET)` — the drizzle update chain. Lookalikes
|
|
149
|
+
// (`hash.update(x).digest()`) have no `.set` and never reach here.
|
|
150
|
+
if (method === "set") {
|
|
151
|
+
const recv = node.expression.expression;
|
|
152
|
+
if (ts.isCallExpression(recv) &&
|
|
153
|
+
ts.isPropertyAccessExpression(recv.expression) &&
|
|
154
|
+
recv.expression.name.text === "update") {
|
|
155
|
+
classify(recv.arguments[0], columnsOfObjectLiteral(node.arguments[0]), recv, "update().set()");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (method === "onConflictDoUpdate") {
|
|
159
|
+
// `<x>.insert(TABLE)…onConflictDoUpdate({ set: {...} })` — an upsert is a
|
|
160
|
+
// conditional transition, so it is classified, not silently skipped.
|
|
161
|
+
const tableArg = tableArgInChain(node);
|
|
162
|
+
if (tableArg) {
|
|
163
|
+
classify(tableArg, columnsOfOnConflictSet(node.arguments[0]), node, "onConflictDoUpdate()");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ts.forEachChild(node, visit);
|
|
168
|
+
}
|
|
169
|
+
visit(src);
|
|
170
|
+
return points;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=scan.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LoadResult } from "../loader/load-model.js";
|
|
2
|
+
import type { T0Result } from "./types.js";
|
|
3
|
+
export interface T0Options {
|
|
4
|
+
/**
|
|
5
|
+
* Absolute path to the repo the anchors should resolve against.
|
|
6
|
+
* Omit to skip anchor-existence entirely (format-only checking) — the
|
|
7
|
+
* right default when there's no target repo checked out yet, e.g. in
|
|
8
|
+
* loopgraph's own CI which only tests the engine against fixtures.
|
|
9
|
+
*/
|
|
10
|
+
repoRoot?: string | undefined;
|
|
11
|
+
/** Promote anchor-existence to a blocking check. Default: advisory (warning). */
|
|
12
|
+
strictAnchorExistence?: boolean | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Runs the full T0 layer: schema validity, id uniqueness, file-per-node
|
|
16
|
+
* filename==id (advisory), cross-node referential integrity, graph acyclicity
|
|
17
|
+
* (Loop.parent / Flow.references — the only two same-kind reference fields, see
|
|
18
|
+
* checkGraphAcyclic), anchor format, and (optionally) anchor existence.
|
|
19
|
+
* Deliberately
|
|
20
|
+
* excludes: INV-1's whole-repo AST writer scan, T1's queue-derivation-
|
|
21
|
+
* chain check, and anything baseline-growth related (that needs a
|
|
22
|
+
* two-snapshot diff, see checkBaselineOnlyDecreases — it's not part of a
|
|
23
|
+
* single-snapshot T0 run). All of T0 must stay cheap and deterministic:
|
|
24
|
+
* no network calls, no LLM.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runT0(load: LoadResult, options?: T0Options): Promise<T0Result>;
|
|
27
|
+
//# sourceMappingURL=t0.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { checkAnchorExistence, checkAnchorFormat, checkFilenameMatchesId, checkGraphAcyclic, checkIdUniqueness, checkReferentialIntegrity, checkSchema, } from "./checks.js";
|
|
2
|
+
import { summarize } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Runs the full T0 layer: schema validity, id uniqueness, file-per-node
|
|
5
|
+
* filename==id (advisory), cross-node referential integrity, graph acyclicity
|
|
6
|
+
* (Loop.parent / Flow.references — the only two same-kind reference fields, see
|
|
7
|
+
* checkGraphAcyclic), anchor format, and (optionally) anchor existence.
|
|
8
|
+
* Deliberately
|
|
9
|
+
* excludes: INV-1's whole-repo AST writer scan, T1's queue-derivation-
|
|
10
|
+
* chain check, and anything baseline-growth related (that needs a
|
|
11
|
+
* two-snapshot diff, see checkBaselineOnlyDecreases — it's not part of a
|
|
12
|
+
* single-snapshot T0 run). All of T0 must stay cheap and deterministic:
|
|
13
|
+
* no network calls, no LLM.
|
|
14
|
+
*/
|
|
15
|
+
export async function runT0(load, options = {}) {
|
|
16
|
+
const violations = [
|
|
17
|
+
...checkSchema(load),
|
|
18
|
+
...checkIdUniqueness(load),
|
|
19
|
+
...checkFilenameMatchesId(load),
|
|
20
|
+
...checkReferentialIntegrity(load.graph),
|
|
21
|
+
...checkGraphAcyclic(load.graph),
|
|
22
|
+
...checkAnchorFormat(load.graph),
|
|
23
|
+
];
|
|
24
|
+
if (options.repoRoot) {
|
|
25
|
+
violations.push(...(await checkAnchorExistence(load.graph, options.repoRoot, {
|
|
26
|
+
strict: options.strictAnchorExistence,
|
|
27
|
+
})));
|
|
28
|
+
}
|
|
29
|
+
return summarize(violations);
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=t0.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type Severity = "error" | "warning";
|
|
2
|
+
export type CheckName = "schema" | "id-uniqueness" | "anchor-format" | "anchor-existence" | "referential-integrity" | "graph-acyclic" | "baseline-growth" | "inv1-write-site" | "filename-id";
|
|
3
|
+
export interface Violation {
|
|
4
|
+
check: CheckName;
|
|
5
|
+
severity: Severity;
|
|
6
|
+
message: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
nodeId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface T0Result {
|
|
11
|
+
/** true iff there are no error-severity violations (warnings don't fail T0). */
|
|
12
|
+
ok: boolean;
|
|
13
|
+
violations: Violation[];
|
|
14
|
+
}
|
|
15
|
+
export declare function summarize(violations: Violation[]): T0Result;
|
|
16
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ImplementationFact } from "../adapters/agenthub/extract.js";
|
|
2
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
3
|
+
/**
|
|
4
|
+
* C2 T1 reconciliation (Proposal 006 Phase 3): which extracted implementation
|
|
5
|
+
* facts (pg-boss queues, setInterval pollers) are NOT registered by any model
|
|
6
|
+
* node. This is the "unregistered route/queue/poller 拦截" check — B1 facts
|
|
7
|
+
* reconciled against the model registry.
|
|
8
|
+
*
|
|
9
|
+
* ADVISORY (T1), not a T0 gate: it reports, it does not fail the build. The
|
|
10
|
+
* enforcement-tier promotion (report → block) is C3, gated on a measured
|
|
11
|
+
* false-positive rate on real target-repo PRs.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* A reconciliation partitions every fact into exactly ONE of three disjoint
|
|
15
|
+
* buckets — `registered + dormantSuppressed + unregistered === total`:
|
|
16
|
+
*/
|
|
17
|
+
export interface Reconciliation {
|
|
18
|
+
/** facts whose file is covered by an ACTIVE model node (a real, modeled loop/junction). */
|
|
19
|
+
registered: ImplementationFact[];
|
|
20
|
+
/**
|
|
21
|
+
* facts whose file is covered ONLY by a dormant Loop (an N-series
|
|
22
|
+
* "baseline-only" registration) — NOT by any active node. A distinct bucket,
|
|
23
|
+
* not a subset of `registered`: a dormant registration quiets the advisory
|
|
24
|
+
* signal without being real modeling, so it is surfaced separately (001 §12
|
|
25
|
+
* 不掩盖缺口 — the quieting must be visible, not hidden inside `registered`).
|
|
26
|
+
*/
|
|
27
|
+
dormantSuppressed: ImplementationFact[];
|
|
28
|
+
/** facts whose file no model node anchors — the advisory findings. */
|
|
29
|
+
unregistered: ImplementationFact[];
|
|
30
|
+
/** repo-relative files the model registers (active + dormant anchors), sorted. */
|
|
31
|
+
coveredFiles: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Queue names declared in some active loop's `consumes_queues` that matched NO
|
|
34
|
+
* extracted fact. A dangling declaration is almost always a typo or a
|
|
35
|
+
* stale/renamed queue — surfaced so it doesn't silently fail to register (and
|
|
36
|
+
* so the model stays honest as the code moves under it).
|
|
37
|
+
*/
|
|
38
|
+
unmatchedConsumedQueues: string[];
|
|
39
|
+
}
|
|
40
|
+
/** File part of an anchor `FILE#SYMBOL` (or the whole string when there is no `#`). */
|
|
41
|
+
export declare function anchorFile(anchor: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* The set of repo-relative files the model registers, via `Loop.anchors` and
|
|
44
|
+
* `Junction` evidence anchors (active + dormant). Non-code evidence anchors
|
|
45
|
+
* (spec/issue files) are folded in too — harmless, they simply never match a
|
|
46
|
+
* code fact's `filePath`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function coveredFiles(graph: ModelGraph): Set<string>;
|
|
49
|
+
/**
|
|
50
|
+
* Reconcile extracted facts against the model's registered anchors.
|
|
51
|
+
*
|
|
52
|
+
* FILE-LEVEL granularity — a fact whose file carries ANY loop/junction anchor
|
|
53
|
+
* counts as covered. This is the SOLE granularity (not a toggled mode): the
|
|
54
|
+
* fact `name` is a queue string / interval marker, a different namespace from
|
|
55
|
+
* the class/function symbols anchors use, so symbol-level matching would need
|
|
56
|
+
* the extractor to also capture each fact's enclosing symbol — future work, and
|
|
57
|
+
* if that granularity is ever added this reconciliation must be revisited.
|
|
58
|
+
* File-level deliberately UNDER-flags (a partially-modeled file hides an extra
|
|
59
|
+
* unregistered loop it also contains) rather than over-flags: an advisory T1
|
|
60
|
+
* signal that cries wolf gets ignored, so the bias is toward silence on
|
|
61
|
+
* ambiguity (001 §12 "不掩盖缺口" is about not hiding KNOWN gaps, not about
|
|
62
|
+
* manufacturing noisy ones).
|
|
63
|
+
*
|
|
64
|
+
* N-series background loops (ttl-renewal, vault-refresh, ...) are suppressed by
|
|
65
|
+
* registering them as minimal (dormant, owner-null) Loop nodes carrying an
|
|
66
|
+
* anchor — that is where "baseline-only" N-loops live (001 §9), NOT in the debt
|
|
67
|
+
* list (debt = dead-state-machines, a structurally different thing).
|
|
68
|
+
*/
|
|
69
|
+
export declare function reconcileFacts(facts: ImplementationFact[], graph: ModelGraph): Reconciliation;
|
|
70
|
+
//# sourceMappingURL=unregistered.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** File part of an anchor `FILE#SYMBOL` (or the whole string when there is no `#`). */
|
|
2
|
+
export function anchorFile(anchor) {
|
|
3
|
+
const hash = anchor.indexOf("#");
|
|
4
|
+
return hash === -1 ? anchor : anchor.slice(0, hash);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Files registered by ACTIVE model nodes (non-dormant loops + all junction
|
|
8
|
+
* evidence). These represent real, modeled coverage. Junctions are always
|
|
9
|
+
* "active" — a junction only exists when a real cross-loop risk was modeled.
|
|
10
|
+
*/
|
|
11
|
+
function activeCoveredFiles(graph) {
|
|
12
|
+
const files = new Set();
|
|
13
|
+
for (const loop of graph.byKind.loop.values())
|
|
14
|
+
if (!loop.dormant)
|
|
15
|
+
for (const a of loop.anchors)
|
|
16
|
+
files.add(anchorFile(a));
|
|
17
|
+
for (const j of graph.byKind.junction.values())
|
|
18
|
+
for (const e of j.evidence)
|
|
19
|
+
files.add(anchorFile(e.anchor));
|
|
20
|
+
return files;
|
|
21
|
+
}
|
|
22
|
+
/** Files registered ONLY by dormant loops (N-series baseline-only registrations). */
|
|
23
|
+
function dormantCoveredFiles(graph) {
|
|
24
|
+
const files = new Set();
|
|
25
|
+
for (const loop of graph.byKind.loop.values())
|
|
26
|
+
if (loop.dormant)
|
|
27
|
+
for (const a of loop.anchors)
|
|
28
|
+
files.add(anchorFile(a));
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* pg-boss queue names declared as consumed by an ACTIVE loop (Loop.consumes_queues,
|
|
33
|
+
* C2 root-fix). A queue fact whose name is here is registered-by-name — its
|
|
34
|
+
* lifecycle loop is modeled even though its producer-registry definition file
|
|
35
|
+
* carries no anchor. Dormant loops don't count (they're a suppression channel,
|
|
36
|
+
* not real modeling — see `dormantSuppressed`).
|
|
37
|
+
*/
|
|
38
|
+
function activeConsumedQueues(graph) {
|
|
39
|
+
const names = new Set();
|
|
40
|
+
for (const loop of graph.byKind.loop.values())
|
|
41
|
+
if (!loop.dormant)
|
|
42
|
+
for (const q of loop.consumes_queues)
|
|
43
|
+
names.add(q);
|
|
44
|
+
return names;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The set of repo-relative files the model registers, via `Loop.anchors` and
|
|
48
|
+
* `Junction` evidence anchors (active + dormant). Non-code evidence anchors
|
|
49
|
+
* (spec/issue files) are folded in too — harmless, they simply never match a
|
|
50
|
+
* code fact's `filePath`.
|
|
51
|
+
*/
|
|
52
|
+
export function coveredFiles(graph) {
|
|
53
|
+
return new Set([...activeCoveredFiles(graph), ...dormantCoveredFiles(graph)]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reconcile extracted facts against the model's registered anchors.
|
|
57
|
+
*
|
|
58
|
+
* FILE-LEVEL granularity — a fact whose file carries ANY loop/junction anchor
|
|
59
|
+
* counts as covered. This is the SOLE granularity (not a toggled mode): the
|
|
60
|
+
* fact `name` is a queue string / interval marker, a different namespace from
|
|
61
|
+
* the class/function symbols anchors use, so symbol-level matching would need
|
|
62
|
+
* the extractor to also capture each fact's enclosing symbol — future work, and
|
|
63
|
+
* if that granularity is ever added this reconciliation must be revisited.
|
|
64
|
+
* File-level deliberately UNDER-flags (a partially-modeled file hides an extra
|
|
65
|
+
* unregistered loop it also contains) rather than over-flags: an advisory T1
|
|
66
|
+
* signal that cries wolf gets ignored, so the bias is toward silence on
|
|
67
|
+
* ambiguity (001 §12 "不掩盖缺口" is about not hiding KNOWN gaps, not about
|
|
68
|
+
* manufacturing noisy ones).
|
|
69
|
+
*
|
|
70
|
+
* N-series background loops (ttl-renewal, vault-refresh, ...) are suppressed by
|
|
71
|
+
* registering them as minimal (dormant, owner-null) Loop nodes carrying an
|
|
72
|
+
* anchor — that is where "baseline-only" N-loops live (001 §9), NOT in the debt
|
|
73
|
+
* list (debt = dead-state-machines, a structurally different thing).
|
|
74
|
+
*/
|
|
75
|
+
export function reconcileFacts(facts, graph) {
|
|
76
|
+
const active = activeCoveredFiles(graph);
|
|
77
|
+
const dormant = dormantCoveredFiles(graph);
|
|
78
|
+
const consumedQueues = activeConsumedQueues(graph);
|
|
79
|
+
const registered = [];
|
|
80
|
+
const dormantSuppressed = [];
|
|
81
|
+
const unregistered = [];
|
|
82
|
+
// Three DISJOINT buckets (active wins over dormant wins over nothing), so
|
|
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).
|
|
87
|
+
const isActive = (f) => active.has(f.filePath) || (f.signal === "pg_boss_queue" && consumedQueues.has(f.name));
|
|
88
|
+
for (const f of facts) {
|
|
89
|
+
if (isActive(f)) {
|
|
90
|
+
registered.push(f);
|
|
91
|
+
}
|
|
92
|
+
else if (dormant.has(f.filePath)) {
|
|
93
|
+
dormantSuppressed.push(f); // covered only by a dormant N-loop — quieted, reported apart
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
unregistered.push(f);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// A declared consumed-queue name that matched no fact is a dangling assertion
|
|
100
|
+
// (typo / renamed / removed queue) — report it so it can't silently rot.
|
|
101
|
+
const factQueueNames = new Set(facts.filter((f) => f.signal === "pg_boss_queue").map((f) => f.name));
|
|
102
|
+
const unmatchedConsumedQueues = [...consumedQueues].filter((q) => !factQueueNames.has(q)).sort();
|
|
103
|
+
return {
|
|
104
|
+
registered,
|
|
105
|
+
dormantSuppressed,
|
|
106
|
+
unregistered,
|
|
107
|
+
coveredFiles: [...new Set([...active, ...dormant])].sort(),
|
|
108
|
+
unmatchedConsumedQueues,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=unregistered.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ModelGraph } from "../loader/model-graph.js";
|
|
2
|
+
/**
|
|
3
|
+
* Renders a Flow (e.g. C1) as a mermaid `flowchart`, per Decision record
|
|
4
|
+
* 004 技术点 5 (Phase 1, C1 only — proposal 001 §12 success-criteria
|
|
5
|
+
* scope, not all 9 Flows). Structure:
|
|
6
|
+
*
|
|
7
|
+
* - Nodes: the Flow itself, every Loop in `traverses`/`guarded_by`, and
|
|
8
|
+
* every Loop referenced by a crossing Junction's `between` (covers
|
|
9
|
+
* Loops that aren't in `traverses`/`guarded_by` at all, e.g. a
|
|
10
|
+
* junction linking two loops outside this flow's primary chain).
|
|
11
|
+
* - Edges: `traverses` rendered as the plain forward sequence; each
|
|
12
|
+
* `guarded_by` Loop as a dashed "guards" edge into the Flow node
|
|
13
|
+
* (flow-level watchdog coverage, not tied to one step); each crossing
|
|
14
|
+
* Junction as its OWN dashed edge, drawn literally from
|
|
15
|
+
* `junction.between[0]` to `junction.between[1]` and labeled with the
|
|
16
|
+
* junction id + risk_class.
|
|
17
|
+
*
|
|
18
|
+
* The junction edges are deliberately NOT inferred by walking
|
|
19
|
+
* `traverses` as a forward chain and matching adjacent pairs — real flow
|
|
20
|
+
* data has junctions that aren't forward-adjacent traversal steps at all
|
|
21
|
+
* (a projection junction can be a backward edge from the last traverses
|
|
22
|
+
* step to an earlier one; a watchdog junction can link a `guarded_by`
|
|
23
|
+
* loop to a traverses step, not two traverses steps). Inferring from
|
|
24
|
+
* adjacency would silently drop both — 2 of the 5 risk classes
|
|
25
|
+
* (projection, watchdog). Drawing every
|
|
26
|
+
* junction from its own literal `between` pair, unconditionally, can't
|
|
27
|
+
* drop any of them.
|
|
28
|
+
*/
|
|
29
|
+
export declare function renderFlowMermaid(graph: ModelGraph, flowId: string): string;
|
|
30
|
+
//# sourceMappingURL=flow-mermaid.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes a title for use inside a mermaid `["..."]` quoted node label.
|
|
3
|
+
* `title` is unconstrained free text (schema: `z.string().min(1)`, no
|
|
4
|
+
* charset restriction), so two things need handling: a literal `"`
|
|
5
|
+
* would close the quoted label early, and a literal newline would
|
|
6
|
+
* split this function's one-line-per-node output across lines,
|
|
7
|
+
* corrupting the line-based structure `renderFlowMermaid` generates —
|
|
8
|
+
* collapsed to a space rather than stripped, so the text stays
|
|
9
|
+
* readable. Ordinary punctuation (`]`, `#`, etc.) needs no escaping:
|
|
10
|
+
* verified against a real mmdc render — mermaid's quoted-bracket label
|
|
11
|
+
* syntax tolerates it as literal text.
|
|
12
|
+
*/
|
|
13
|
+
function quoteLabel(text) {
|
|
14
|
+
return text.replace(/"/g, "'").replace(/[\r\n]+/g, " ");
|
|
15
|
+
}
|
|
16
|
+
function nodeDecl(id, title) {
|
|
17
|
+
return ` ${id}["${id}: ${quoteLabel(title)}"]`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders a Flow (e.g. C1) as a mermaid `flowchart`, per Decision record
|
|
21
|
+
* 004 技术点 5 (Phase 1, C1 only — proposal 001 §12 success-criteria
|
|
22
|
+
* scope, not all 9 Flows). Structure:
|
|
23
|
+
*
|
|
24
|
+
* - Nodes: the Flow itself, every Loop in `traverses`/`guarded_by`, and
|
|
25
|
+
* every Loop referenced by a crossing Junction's `between` (covers
|
|
26
|
+
* Loops that aren't in `traverses`/`guarded_by` at all, e.g. a
|
|
27
|
+
* junction linking two loops outside this flow's primary chain).
|
|
28
|
+
* - Edges: `traverses` rendered as the plain forward sequence; each
|
|
29
|
+
* `guarded_by` Loop as a dashed "guards" edge into the Flow node
|
|
30
|
+
* (flow-level watchdog coverage, not tied to one step); each crossing
|
|
31
|
+
* Junction as its OWN dashed edge, drawn literally from
|
|
32
|
+
* `junction.between[0]` to `junction.between[1]` and labeled with the
|
|
33
|
+
* junction id + risk_class.
|
|
34
|
+
*
|
|
35
|
+
* The junction edges are deliberately NOT inferred by walking
|
|
36
|
+
* `traverses` as a forward chain and matching adjacent pairs — real flow
|
|
37
|
+
* data has junctions that aren't forward-adjacent traversal steps at all
|
|
38
|
+
* (a projection junction can be a backward edge from the last traverses
|
|
39
|
+
* step to an earlier one; a watchdog junction can link a `guarded_by`
|
|
40
|
+
* loop to a traverses step, not two traverses steps). Inferring from
|
|
41
|
+
* adjacency would silently drop both — 2 of the 5 risk classes
|
|
42
|
+
* (projection, watchdog). Drawing every
|
|
43
|
+
* junction from its own literal `between` pair, unconditionally, can't
|
|
44
|
+
* drop any of them.
|
|
45
|
+
*/
|
|
46
|
+
export function renderFlowMermaid(graph, flowId) {
|
|
47
|
+
const flow = graph.byKind.flow.get(flowId);
|
|
48
|
+
if (!flow)
|
|
49
|
+
throw new Error(`renderFlowMermaid: no such flow "${flowId}"`);
|
|
50
|
+
const crossingJunctions = flow.crosses
|
|
51
|
+
.map((id) => graph.byKind.junction.get(id))
|
|
52
|
+
.filter((j) => j !== undefined);
|
|
53
|
+
const nodeIds = new Set([
|
|
54
|
+
...flow.traverses,
|
|
55
|
+
...flow.guarded_by,
|
|
56
|
+
...crossingJunctions.flatMap((j) => j.between),
|
|
57
|
+
]);
|
|
58
|
+
const lines = ["flowchart TB", nodeDecl(flow.id, flow.title)];
|
|
59
|
+
for (const loopId of nodeIds) {
|
|
60
|
+
const loop = graph.byKind.loop.get(loopId);
|
|
61
|
+
lines.push(nodeDecl(loopId, loop?.title ?? loopId));
|
|
62
|
+
}
|
|
63
|
+
lines.push("");
|
|
64
|
+
if (flow.traverses.length > 0) {
|
|
65
|
+
const [first, ...rest] = flow.traverses;
|
|
66
|
+
lines.push(` ${flow.id} --> ${first}`);
|
|
67
|
+
let prev = first;
|
|
68
|
+
for (const next of rest) {
|
|
69
|
+
lines.push(` ${prev} --> ${next}`);
|
|
70
|
+
prev = next;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const guardId of flow.guarded_by) {
|
|
74
|
+
lines.push(` ${guardId} -.->|guards| ${flow.id}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("");
|
|
77
|
+
for (const junction of crossingJunctions) {
|
|
78
|
+
const [from, to] = junction.between;
|
|
79
|
+
if (from === undefined || to === undefined)
|
|
80
|
+
continue; // schema allows a 1-element `between`; no edge to draw
|
|
81
|
+
// The label carries the endpoints explicitly (`from→to`) so a reader never
|
|
82
|
+
// has to infer direction from the auto-layout (Report 005 §5 建议 1): mermaid
|
|
83
|
+
// routes non-adjacent/reverse dashed edges close together — and readers
|
|
84
|
+
// misread which end is which. Stating it in the label removes the ambiguity.
|
|
85
|
+
lines.push(` ${from} -.->|"${junction.id}: ${junction.risk_class} (${from}→${to})"| ${to}`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=flow-mermaid.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type MermaidValidationResult = {
|
|
2
|
+
status: "valid";
|
|
3
|
+
} | {
|
|
4
|
+
status: "invalid";
|
|
5
|
+
error: string;
|
|
6
|
+
} | {
|
|
7
|
+
status: "unavailable";
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export interface ValidateMermaidOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Overrides the resolved `mmdc` script path. Test-only seam: lets
|
|
13
|
+
* test/validate-mermaid.test.ts force the "unavailable" branch
|
|
14
|
+
* deterministically (a bogus path) without needing to actually
|
|
15
|
+
* uninstall @mermaid-js/mermaid-cli from this repo's own node_modules.
|
|
16
|
+
*/
|
|
17
|
+
mmdcBinPath?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Renders `mermaidText` with mermaid-cli (`mmdc`) to a throwaway SVG and
|
|
21
|
+
* reports whether it actually rendered — the "写出即验证" enforcement
|
|
22
|
+
* from Decision record 004 技术点 5. A generated diagram nobody opens
|
|
23
|
+
* until much later isn't validated, it's just written; this is the
|
|
24
|
+
* module that makes "validated" mean something.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT on loopgraph's default `view` CLI path (see
|
|
27
|
+
* src/cli/commands/view.ts): mmdc needs a real puppeteer-launched
|
|
28
|
+
* browser (~300MB Chromium download on first install), which the
|
|
29
|
+
* project's own lightness principle (proposal 001 §6: views generation
|
|
30
|
+
* isn't in the CI gating path) says shouldn't be a hard runtime
|
|
31
|
+
* requirement for every consumer that installs loopgraph. It's a
|
|
32
|
+
* devDependency here so loopgraph's OWN test suite can prove the
|
|
33
|
+
* generator produces valid mermaid (test/validate-mermaid.test.ts,
|
|
34
|
+
* test/view-cli.test.ts), and available to end users only when they
|
|
35
|
+
* opt in with `loopgraph view --validate` AND have separately installed
|
|
36
|
+
* `@mermaid-js/mermaid-cli` themselves.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateMermaid(mermaidText: string, options?: ValidateMermaidOptions): Promise<MermaidValidationResult>;
|
|
39
|
+
//# sourceMappingURL=validate-mermaid.d.ts.map
|