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,183 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConsistencyStatus, DebtCategory, RiskClass } from "./status.js";
|
|
3
|
+
/**
|
|
4
|
+
* IDs are re-used verbatim from the source research documents so that the
|
|
5
|
+
* drift test (test/drift.test.ts) can diff model IDs against IDs extracted
|
|
6
|
+
* straight out of the PR tables, with no hand-maintained translation layer.
|
|
7
|
+
*/
|
|
8
|
+
export const LoopId = z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^[LN]\d{1,2}[a-z]?$/, "loop id must look like L1, L1a, N32, etc.");
|
|
11
|
+
export const FlowId = z.string().regex(/^C\d$/, "flow id must look like C1..C9");
|
|
12
|
+
export const FeatureId = z.string().regex(/^F-[a-z0-9-]+$/, "feature id must look like F-xxx");
|
|
13
|
+
export const JunctionId = z.string().regex(/^J-[a-z0-9_-]+$/, "junction id must look like J-xxx");
|
|
14
|
+
export const ScenarioId = z
|
|
15
|
+
.string()
|
|
16
|
+
.regex(/^GWT-[A-Za-z0-9]+-\d{3}$/, "scenario id must look like GWT-<flow|loop>-001");
|
|
17
|
+
/**
|
|
18
|
+
* Max length of `Evidence.note`. `note` exists only to help a reader
|
|
19
|
+
* *locate* the anchored evidence (a line range, a function name, a test-name
|
|
20
|
+
* fragment) — NOT to narrate causal reasoning or historical background.
|
|
21
|
+
* The cap is a mechanical backstop for that intent: prose explanations run
|
|
22
|
+
* long, pointers stay short. Kept as a named const so the schema test and
|
|
23
|
+
* any future review tooling reference the same number.
|
|
24
|
+
*/
|
|
25
|
+
export const EVIDENCE_NOTE_MAX = 200;
|
|
26
|
+
/**
|
|
27
|
+
* Evidence is embedded inside Junction (evidence: Evidence[]) rather than
|
|
28
|
+
* a standalone top-level file — it only makes sense bound to the claim it
|
|
29
|
+
* proves. Scenario does NOT carry Evidence directly: its `verified_by`
|
|
30
|
+
* field holds plain test/E2E anchor strings instead, since GWT→test
|
|
31
|
+
* binding is a simpler 1:many mapping than Junction's need for typed,
|
|
32
|
+
* multi-kind evidence.
|
|
33
|
+
*
|
|
34
|
+
* `kind` taxonomy:
|
|
35
|
+
* - Runtime/code evidence: test / durable_event / e2e / metric / trace /
|
|
36
|
+
* log / code — an anchor that resolves to a projection or observation of
|
|
37
|
+
* the running system.
|
|
38
|
+
* - Intent/planning evidence (Proposal 006 A1): `spec` points at a
|
|
39
|
+
* requirement/design statement (a spec section, an ADR line), `issue`
|
|
40
|
+
* points at a tracked work item (a GitHub issue/PR reference). These let
|
|
41
|
+
* a junction cite "this handoff is deliberately deferred, see #NNNN" or
|
|
42
|
+
* "the poller's settled≠done semantics is specified here" without dressing
|
|
43
|
+
* a planning pointer up as a runtime observation. Because they are the
|
|
44
|
+
* easiest kind to smuggle prose into, `note` on these is held to the same
|
|
45
|
+
* anchor-only, pointer-not-narrative discipline as every other kind (see
|
|
46
|
+
* `EVIDENCE_NOTE_MAX`); reviewers of any content PR treat a `spec`/`issue`
|
|
47
|
+
* note that argues causation or recounts history as a defect.
|
|
48
|
+
*/
|
|
49
|
+
export const Evidence = z.object({
|
|
50
|
+
id: z.string().min(1),
|
|
51
|
+
kind: z.enum(["test", "durable_event", "e2e", "metric", "trace", "log", "code", "spec", "issue"]),
|
|
52
|
+
anchor: z.string().min(1),
|
|
53
|
+
source: z.string().optional(),
|
|
54
|
+
transport: z.enum(["direct", "relay", "durable_event"]).optional(),
|
|
55
|
+
binding: z
|
|
56
|
+
.object({
|
|
57
|
+
image_revision: z.enum(["required", "optional"]),
|
|
58
|
+
})
|
|
59
|
+
.optional(),
|
|
60
|
+
confidence: z.enum(["high", "medium", "low"]).optional(),
|
|
61
|
+
// Anchor-locating aid only (line range / function name / test-name
|
|
62
|
+
// fragment). No causal reasoning, no historical background — long prose
|
|
63
|
+
// belongs in the source doc the `spec`/`issue` anchor points at, not here.
|
|
64
|
+
note: z.string().max(EVIDENCE_NOTE_MAX).optional(),
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Loop covers both ordinary top-level loops (L1, N19, ...) and the
|
|
68
|
+
* "(内嵌)" embedded submachines (L1a/L1b/L3a/L20a) that share a parent's
|
|
69
|
+
* boundary but carry their own state. Dormant/unwired loops like N8
|
|
70
|
+
* ("M2 休眠") have owner: null and dormant: true instead of being dropped.
|
|
71
|
+
*/
|
|
72
|
+
export const Loop = z.object({
|
|
73
|
+
id: LoopId,
|
|
74
|
+
kind: z.literal("loop"),
|
|
75
|
+
title: z.string().min(1),
|
|
76
|
+
boundary: z.string().min(1),
|
|
77
|
+
owner: z.string().min(1).nullable(),
|
|
78
|
+
section: z.string().optional(),
|
|
79
|
+
parent: LoopId.optional(),
|
|
80
|
+
embedded: z.boolean().optional(),
|
|
81
|
+
dormant: z.boolean().optional(),
|
|
82
|
+
status: ConsistencyStatus.default("unverified"),
|
|
83
|
+
anchors: z.array(z.string()).default([]),
|
|
84
|
+
/**
|
|
85
|
+
* pg-boss queue names this loop CONSUMES (Proposal 006 C2 root-fix). A queue is
|
|
86
|
+
* defined at its producer/enqueue site (e.g. a shared `services.ts` registry)
|
|
87
|
+
* but its lifecycle belongs to the consuming loop, which may live in a
|
|
88
|
+
* different file. Declaring the names here lets reconciliation register a queue
|
|
89
|
+
* fact by NAME — so a producer-registry definition is not a false "unregistered"
|
|
90
|
+
* just because its file carries no anchor. This is an explicit modeling
|
|
91
|
+
* assertion ("this loop consumes order:process"), verified against the
|
|
92
|
+
* consumer's queue handler; it is NOT a way to silence facts (that would be a
|
|
93
|
+
* dormant loop) and NOT a synthetic node (a registry file is not a loop).
|
|
94
|
+
*/
|
|
95
|
+
consumes_queues: z.array(z.string()).default([]),
|
|
96
|
+
scenarios: z.array(ScenarioId).default([]),
|
|
97
|
+
notes: z.string().optional(),
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* Flow = composite flow (C1..C9). `traverses` is the ordered loop sequence;
|
|
101
|
+
* `guarded_by` holds watchdog loops (e.g. C1's L9) that don't sit in the
|
|
102
|
+
* primary sequence but cover it; `references` holds sub-flow composition
|
|
103
|
+
* (e.g. C1 references C2/C3 as expandable sub-chains).
|
|
104
|
+
*/
|
|
105
|
+
export const Flow = z.object({
|
|
106
|
+
id: FlowId,
|
|
107
|
+
kind: z.literal("flow"),
|
|
108
|
+
title: z.string().min(1),
|
|
109
|
+
traverses: z.array(LoopId).default([]),
|
|
110
|
+
guarded_by: z.array(LoopId).default([]),
|
|
111
|
+
crosses: z.array(JunctionId).default([]),
|
|
112
|
+
references: z.array(FlowId).default([]),
|
|
113
|
+
risk_notes: z.string().optional(),
|
|
114
|
+
status: ConsistencyStatus.default("unverified"),
|
|
115
|
+
});
|
|
116
|
+
export const Junction = z.object({
|
|
117
|
+
id: JunctionId,
|
|
118
|
+
kind: z.literal("junction"),
|
|
119
|
+
title: z.string().optional(),
|
|
120
|
+
risk_class: RiskClass,
|
|
121
|
+
between: z.array(LoopId).min(1),
|
|
122
|
+
scenarios: z.array(ScenarioId).default([]),
|
|
123
|
+
evidence: z.array(Evidence).default([]),
|
|
124
|
+
status: ConsistencyStatus.default("unverified"),
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* Selector for effective-constraints propagation (Decision record 004,
|
|
128
|
+
* 技术点 2): decides which nodes an Invariant-flavored Scenario applies
|
|
129
|
+
* to automatically, without hand-listing the scenario id in every
|
|
130
|
+
* relevant node's `scenarios` array. Resolved at query time by
|
|
131
|
+
* src/query/effective-constraints.ts, never materialized — see that
|
|
132
|
+
* module for match semantics (owner_match is substring containment with
|
|
133
|
+
* optional `*` wildcard, NOT prefix-anchored or POSIX glob).
|
|
134
|
+
*/
|
|
135
|
+
export const AppliesTo = z.object({
|
|
136
|
+
nodes: z.array(z.string()).default([]),
|
|
137
|
+
owner_match: z.string().min(1).optional(), // empty string would match every non-dormant Loop — reject rather than allow the footgun
|
|
138
|
+
});
|
|
139
|
+
export const Scenario = z.object({
|
|
140
|
+
id: ScenarioId,
|
|
141
|
+
kind: z.literal("scenario"),
|
|
142
|
+
given: z.string().min(1),
|
|
143
|
+
when: z.string().min(1),
|
|
144
|
+
// biome-ignore lint/suspicious/noThenProperty: Given/When/Then is the GWT domain vocabulary, not a thenable
|
|
145
|
+
then: z.string().min(1),
|
|
146
|
+
level: z.enum(["unit", "focused-integration", "contract", "integration", "e2e", "operational"]),
|
|
147
|
+
verified_by: z.array(z.string()).default([]),
|
|
148
|
+
applies_to: AppliesTo.optional(),
|
|
149
|
+
});
|
|
150
|
+
export const Feature = z.object({
|
|
151
|
+
id: FeatureId,
|
|
152
|
+
kind: z.literal("feature"),
|
|
153
|
+
title: z.string().min(1),
|
|
154
|
+
contains: z.array(FlowId).default([]),
|
|
155
|
+
status: ConsistencyStatus.default("unverified"),
|
|
156
|
+
});
|
|
157
|
+
/**
|
|
158
|
+
* DebtEntry is deliberately a different shape from Loop: the 6
|
|
159
|
+
* dead-state-machine tables and 2 deferred loops from the seed baseline describe
|
|
160
|
+
* something that LOOKS like a loop (has a status column / was scoped as
|
|
161
|
+
* a loop) but fails the "independent advance mechanism" test — they are
|
|
162
|
+
* baseline debt, not behavior to model as Loop nodes.
|
|
163
|
+
*/
|
|
164
|
+
export const DebtId = z.string().regex(/^DEBT-[A-Za-z0-9-]+$/, "debt id must look like DEBT-xxx");
|
|
165
|
+
export const DebtEntry = z.object({
|
|
166
|
+
id: DebtId,
|
|
167
|
+
kind: z.literal("debt"),
|
|
168
|
+
category: DebtCategory,
|
|
169
|
+
subject: z.string().min(1),
|
|
170
|
+
claim: z.string().optional(),
|
|
171
|
+
reality: z.string().min(1),
|
|
172
|
+
owner: z.string().optional(),
|
|
173
|
+
removal_condition: z.string().optional(),
|
|
174
|
+
});
|
|
175
|
+
export const ModelNode = z.discriminatedUnion("kind", [
|
|
176
|
+
Feature,
|
|
177
|
+
Flow,
|
|
178
|
+
Loop,
|
|
179
|
+
Junction,
|
|
180
|
+
Scenario,
|
|
181
|
+
DebtEntry,
|
|
182
|
+
]);
|
|
183
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Six consistency states a model node can be in, per proposal 001 §3.2.
|
|
4
|
+
* Default for freshly-imported seed nodes is "unverified": the real
|
|
5
|
+
* implementation exists in the target repo, but the model hasn't yet
|
|
6
|
+
* attached enough Scenario/Evidence to call it conformant.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ConsistencyStatus: z.ZodEnum<["conformant", "unimplemented", "unverified", "drifted", "unregistered", "deprecated"]>;
|
|
9
|
+
export type ConsistencyStatus = z.infer<typeof ConsistencyStatus>;
|
|
10
|
+
/**
|
|
11
|
+
* The 5 junction risk classes (衔接点风险模式) — the frozen taxonomy for the seed.
|
|
12
|
+
* Every Junction must classify itself under exactly one of these.
|
|
13
|
+
*/
|
|
14
|
+
export declare const RiskClass: z.ZodEnum<["handoff", "idempotency", "projection", "failure_propagation", "watchdog"]>;
|
|
15
|
+
export type RiskClass = z.infer<typeof RiskClass>;
|
|
16
|
+
export declare const DebtCategory: z.ZodEnum<["dead_state_machine", "deferred", "other"]>;
|
|
17
|
+
export type DebtCategory = z.infer<typeof DebtCategory>;
|
|
18
|
+
//# sourceMappingURL=status.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Six consistency states a model node can be in, per proposal 001 §3.2.
|
|
4
|
+
* Default for freshly-imported seed nodes is "unverified": the real
|
|
5
|
+
* implementation exists in the target repo, but the model hasn't yet
|
|
6
|
+
* attached enough Scenario/Evidence to call it conformant.
|
|
7
|
+
*/
|
|
8
|
+
export const ConsistencyStatus = z.enum([
|
|
9
|
+
"conformant",
|
|
10
|
+
"unimplemented",
|
|
11
|
+
"unverified",
|
|
12
|
+
"drifted",
|
|
13
|
+
"unregistered",
|
|
14
|
+
"deprecated",
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* The 5 junction risk classes (衔接点风险模式) — the frozen taxonomy for the seed.
|
|
18
|
+
* Every Junction must classify itself under exactly one of these.
|
|
19
|
+
*/
|
|
20
|
+
export const RiskClass = z.enum([
|
|
21
|
+
"handoff",
|
|
22
|
+
"idempotency",
|
|
23
|
+
"projection",
|
|
24
|
+
"failure_propagation",
|
|
25
|
+
"watchdog",
|
|
26
|
+
]);
|
|
27
|
+
export const DebtCategory = z.enum(["dead_state_machine", "deferred", "other"]);
|
|
28
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A point-in-time fingerprint attached to any side-channel file loopgraph
|
|
3
|
+
* writes (Decision record 004, 技术点 4, CodeGraph-inspired: side-channel
|
|
4
|
+
* output must carry an explicit staleness signal instead of silently
|
|
5
|
+
* looking as current as a freshly-computed one).
|
|
6
|
+
*
|
|
7
|
+
* `modelContentHash` is the ONLY field `isStale` compares against — it's
|
|
8
|
+
* a hash over every model YAML file's own bytes, so it changes the
|
|
9
|
+
* instant model data changes, independent of git (uncommitted edits,
|
|
10
|
+
* detached checkouts, and non-git exports all still get a real hash).
|
|
11
|
+
* `generatedAt`/`repoHead`/`repoDirty` are read-only context for a human
|
|
12
|
+
* looking at the file — never treat wall-clock age or repo dirtiness as
|
|
13
|
+
* a staleness signal by itself: time passing doesn't make a view wrong,
|
|
14
|
+
* and a repo can be dirty for reasons unrelated to the model.
|
|
15
|
+
*/
|
|
16
|
+
export interface StalenessStamp {
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
modelContentHash: string;
|
|
19
|
+
repoHead: string | null;
|
|
20
|
+
repoDirty: boolean | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Deterministic fingerprint of every *.yaml/*.yml file under `modelDir`,
|
|
24
|
+
* independent of file iteration order (sorted before hashing) and of git
|
|
25
|
+
* (reads working-tree bytes directly, so uncommitted edits change the
|
|
26
|
+
* hash immediately — the same "reflect the working tree, not last
|
|
27
|
+
* commit" posture as the rest of loadModel). This deliberately hashes
|
|
28
|
+
* the WHOLE model dir rather than per-flow/per-node: it will over-report
|
|
29
|
+
* staleness when an unrelated node changes, which is the safe direction
|
|
30
|
+
* for a "don't trust this without checking" signal — a false "maybe
|
|
31
|
+
* stale" costs a re-run; a false "definitely fresh" costs a wrong
|
|
32
|
+
* decision made on stale data.
|
|
33
|
+
*/
|
|
34
|
+
export declare function computeModelContentHash(modelDir: string): Promise<string>;
|
|
35
|
+
export declare function computeStalenessStamp(modelDir: string, repoRoot: string): Promise<StalenessStamp>;
|
|
36
|
+
/** Renders `stamp` as an HTML comment block, prepended to a generated side-channel file. */
|
|
37
|
+
export declare function formatStalenessBanner(stamp: StalenessStamp): string;
|
|
38
|
+
/**
|
|
39
|
+
* Inverse of `formatStalenessBanner`. Returns undefined if `text` has no
|
|
40
|
+
* recognizable banner. Parses fields by splitting on lines and matching
|
|
41
|
+
* each line's start against `BANNER_FIELD_KEYS` rather than building a
|
|
42
|
+
* `RegExp` per field — field keys are fixed string literals here, never
|
|
43
|
+
* user input, but avoiding per-call dynamic regex construction sidesteps
|
|
44
|
+
* the need to reason about escaping altogether, and keeps every field's
|
|
45
|
+
* value confined to its own line (a value can never accidentally
|
|
46
|
+
* swallow a neighboring line the way a `.*` non-multiline capture over
|
|
47
|
+
* a shared block could invite as this function grows more fields).
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseStalenessBanner(text: string): StalenessStamp | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a previously-generated stamp no longer reflects the current
|
|
52
|
+
* model. Compares `modelContentHash` ONLY — see the field's docstring on
|
|
53
|
+
* `StalenessStamp` for why wall-clock time and repo dirtiness must not
|
|
54
|
+
* factor in.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isStale(generated: StalenessStamp, current: StalenessStamp): boolean;
|
|
57
|
+
//# sourceMappingURL=staleness.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { relative } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { findYamlFiles } from "./loader/find-yaml-files.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Deterministic fingerprint of every *.yaml/*.yml file under `modelDir`,
|
|
10
|
+
* independent of file iteration order (sorted before hashing) and of git
|
|
11
|
+
* (reads working-tree bytes directly, so uncommitted edits change the
|
|
12
|
+
* hash immediately — the same "reflect the working tree, not last
|
|
13
|
+
* commit" posture as the rest of loadModel). This deliberately hashes
|
|
14
|
+
* the WHOLE model dir rather than per-flow/per-node: it will over-report
|
|
15
|
+
* staleness when an unrelated node changes, which is the safe direction
|
|
16
|
+
* for a "don't trust this without checking" signal — a false "maybe
|
|
17
|
+
* stale" costs a re-run; a false "definitely fresh" costs a wrong
|
|
18
|
+
* decision made on stale data.
|
|
19
|
+
*/
|
|
20
|
+
export async function computeModelContentHash(modelDir) {
|
|
21
|
+
const files = (await findYamlFiles(modelDir)).sort();
|
|
22
|
+
const hash = createHash("sha256");
|
|
23
|
+
for (const absPath of files) {
|
|
24
|
+
const relPath = relative(modelDir, absPath);
|
|
25
|
+
const content = await readFile(absPath, "utf8");
|
|
26
|
+
hash.update(relPath);
|
|
27
|
+
hash.update("\0");
|
|
28
|
+
hash.update(content);
|
|
29
|
+
hash.update("\0");
|
|
30
|
+
}
|
|
31
|
+
return hash.digest("hex");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Best-effort `git rev-parse HEAD` / `git status --porcelain` against
|
|
35
|
+
* `repoRoot`. Returns `{ head: null, dirty: null }` for anything that
|
|
36
|
+
* isn't a usable git checkout (no git binary, not a repo, no commits
|
|
37
|
+
* yet) rather than throwing — a staleness stamp must still be produced
|
|
38
|
+
* for a plain export or a fresh `git init` with no commits.
|
|
39
|
+
*/
|
|
40
|
+
async function readGitInfo(repoRoot) {
|
|
41
|
+
try {
|
|
42
|
+
const [{ stdout: head }, { stdout: status }] = await Promise.all([
|
|
43
|
+
execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoRoot }),
|
|
44
|
+
execFileAsync("git", ["status", "--porcelain"], { cwd: repoRoot }),
|
|
45
|
+
]);
|
|
46
|
+
return { head: head.trim(), dirty: status.trim().length > 0 };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { head: null, dirty: null };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function computeStalenessStamp(modelDir, repoRoot) {
|
|
53
|
+
const [modelContentHash, gitInfo] = await Promise.all([
|
|
54
|
+
computeModelContentHash(modelDir),
|
|
55
|
+
readGitInfo(repoRoot),
|
|
56
|
+
]);
|
|
57
|
+
return {
|
|
58
|
+
generatedAt: new Date().toISOString(),
|
|
59
|
+
modelContentHash,
|
|
60
|
+
repoHead: gitInfo.head,
|
|
61
|
+
repoDirty: gitInfo.dirty,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const UNKNOWN_HEAD = "unknown (not a git repo or no commits yet)";
|
|
65
|
+
const UNKNOWN_DIRTY = "unknown";
|
|
66
|
+
/**
|
|
67
|
+
* Marks the opening of the banner's HTML comment. A bare `<!--` isn't
|
|
68
|
+
* enough of an anchor: a generated side-channel file also embeds a
|
|
69
|
+
* mermaid block (see src/views/flow-mermaid.ts / src/cli/commands/
|
|
70
|
+
* view.ts), and a future caller could reasonably run
|
|
71
|
+
* `parseStalenessBanner` against a whole file rather than the banner
|
|
72
|
+
* text alone — the non-greedy `<!--...-->` match must not lock onto
|
|
73
|
+
* some unrelated comment that happens to appear first.
|
|
74
|
+
*/
|
|
75
|
+
const BANNER_MARKER = "loopgraph-staleness-stamp";
|
|
76
|
+
/** Renders `stamp` as an HTML comment block, prepended to a generated side-channel file. */
|
|
77
|
+
export function formatStalenessBanner(stamp) {
|
|
78
|
+
return [
|
|
79
|
+
`<!-- ${BANNER_MARKER}`,
|
|
80
|
+
"This file is a point-in-time snapshot, not a live view. Before trusting",
|
|
81
|
+
"it, recompute model_content_hash (src/staleness.ts computeModelContentHash)",
|
|
82
|
+
"against the current model/ dir and compare — a matching generated_at or",
|
|
83
|
+
"repo_head does NOT by itself mean the model hasn't changed since.",
|
|
84
|
+
`generated_at: ${stamp.generatedAt}`,
|
|
85
|
+
`model_content_hash: ${stamp.modelContentHash}`,
|
|
86
|
+
`repo_head: ${stamp.repoHead ?? UNKNOWN_HEAD}`,
|
|
87
|
+
`repo_dirty: ${stamp.repoDirty === null ? UNKNOWN_DIRTY : stamp.repoDirty}`,
|
|
88
|
+
"-->",
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The only lines `parseStalenessBanner` will ever treat as a field —
|
|
93
|
+
* every other line in the banner (the explanatory prose) is ignored
|
|
94
|
+
* outright, even if it happens to contain a `": "` substring. Matching
|
|
95
|
+
* against this fixed allowlist instead of "the first `": "` on any
|
|
96
|
+
* line" means the prose is free to read naturally without the parser
|
|
97
|
+
* silently misreading it as a spurious field.
|
|
98
|
+
*/
|
|
99
|
+
const BANNER_FIELD_KEYS = [
|
|
100
|
+
"generated_at",
|
|
101
|
+
"model_content_hash",
|
|
102
|
+
"repo_head",
|
|
103
|
+
"repo_dirty",
|
|
104
|
+
];
|
|
105
|
+
/**
|
|
106
|
+
* Inverse of `formatStalenessBanner`. Returns undefined if `text` has no
|
|
107
|
+
* recognizable banner. Parses fields by splitting on lines and matching
|
|
108
|
+
* each line's start against `BANNER_FIELD_KEYS` rather than building a
|
|
109
|
+
* `RegExp` per field — field keys are fixed string literals here, never
|
|
110
|
+
* user input, but avoiding per-call dynamic regex construction sidesteps
|
|
111
|
+
* the need to reason about escaping altogether, and keeps every field's
|
|
112
|
+
* value confined to its own line (a value can never accidentally
|
|
113
|
+
* swallow a neighboring line the way a `.*` non-multiline capture over
|
|
114
|
+
* a shared block could invite as this function grows more fields).
|
|
115
|
+
*/
|
|
116
|
+
export function parseStalenessBanner(text) {
|
|
117
|
+
const block = text.match(new RegExp(`<!--\\s*${BANNER_MARKER}([\\s\\S]*?)-->`))?.[1];
|
|
118
|
+
if (!block)
|
|
119
|
+
return undefined;
|
|
120
|
+
const fields = new Map();
|
|
121
|
+
for (const line of block.split("\n")) {
|
|
122
|
+
const key = BANNER_FIELD_KEYS.find((k) => line.startsWith(`${k}: `));
|
|
123
|
+
if (key)
|
|
124
|
+
fields.set(key, line.slice(key.length + 2).trim());
|
|
125
|
+
}
|
|
126
|
+
const generatedAt = fields.get("generated_at");
|
|
127
|
+
const modelContentHash = fields.get("model_content_hash");
|
|
128
|
+
if (!generatedAt || !modelContentHash)
|
|
129
|
+
return undefined;
|
|
130
|
+
const repoHead = fields.get("repo_head");
|
|
131
|
+
const repoDirty = fields.get("repo_dirty");
|
|
132
|
+
return {
|
|
133
|
+
generatedAt,
|
|
134
|
+
modelContentHash,
|
|
135
|
+
repoHead: repoHead && repoHead !== UNKNOWN_HEAD ? repoHead : null,
|
|
136
|
+
repoDirty: repoDirty === "true" ? true : repoDirty === "false" ? false : null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether a previously-generated stamp no longer reflects the current
|
|
141
|
+
* model. Compares `modelContentHash` ONLY — see the field's docstring on
|
|
142
|
+
* `StalenessStamp` for why wall-clock time and repo dirtiness must not
|
|
143
|
+
* factor in.
|
|
144
|
+
*/
|
|
145
|
+
export function isStale(generated, current) {
|
|
146
|
+
return generated.modelContentHash !== current.modelContentHash;
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=staleness.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function isValidAnchorFormat(anchor: string): boolean;
|
|
2
|
+
/** For a file-symbol anchor, the file path portion (before "#"). Table anchors have none. */
|
|
3
|
+
export declare function anchorFilePath(anchor: string): string | undefined;
|
|
4
|
+
//# sourceMappingURL=anchor.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two anchor shapes are allowed:
|
|
3
|
+
* - file-symbol: "packages/core/src/idle-watcher.ts#IdleWatcher"
|
|
4
|
+
* "packages/schemas#EventSchema.warning"
|
|
5
|
+
* - db-table: "jobs_table.payload" or bare "sandboxes"
|
|
6
|
+
* The distinguishing feature is the "#" — file-symbol anchors always
|
|
7
|
+
* have one, db-table anchors never do. This is intentionally lenient:
|
|
8
|
+
* it is a syntax check, not a claim that the symbol/table exists.
|
|
9
|
+
*
|
|
10
|
+
* File-symbol paths are always relative to the target repo's checkout
|
|
11
|
+
* root, never to loopgraph's own CWD or the model
|
|
12
|
+
* YAML's own location — `checkAnchorExistence(graph, repoRoot, ...)` in
|
|
13
|
+
* checks.ts takes `repoRoot` as an explicit required parameter for
|
|
14
|
+
* exactly this reason, so anchor resolution doesn't depend on which
|
|
15
|
+
* directory the CLI happens to be invoked from.
|
|
16
|
+
*/
|
|
17
|
+
const FILE_SYMBOL_ANCHOR = /^[\w./-]+#[\w.]+$/;
|
|
18
|
+
const TABLE_ANCHOR = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/;
|
|
19
|
+
export function isValidAnchorFormat(anchor) {
|
|
20
|
+
if (anchor.length === 0)
|
|
21
|
+
return false;
|
|
22
|
+
if (anchor.includes("#"))
|
|
23
|
+
return FILE_SYMBOL_ANCHOR.test(anchor);
|
|
24
|
+
return TABLE_ANCHOR.test(anchor);
|
|
25
|
+
}
|
|
26
|
+
/** For a file-symbol anchor, the file path portion (before "#"). Table anchors have none. */
|
|
27
|
+
export function anchorFilePath(anchor) {
|
|
28
|
+
if (!anchor.includes("#"))
|
|
29
|
+
return undefined;
|
|
30
|
+
return anchor.split("#")[0];
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=anchor.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Violation } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Debt baseline is allowed to shrink (pay off debt) but never grow: a PR
|
|
4
|
+
* must not introduce a debt id that wasn't already in the baseline it's
|
|
5
|
+
* diffing against. Content edits to *existing* ids (e.g. clarifying the
|
|
6
|
+
* `reality` text) are intentionally not checked here — judging whether a
|
|
7
|
+
* wording change "扩大了既有债务" is a semantic call, not a mechanical
|
|
8
|
+
* one, and belongs to human review, not T0.
|
|
9
|
+
*/
|
|
10
|
+
export declare function checkBaselineOnlyDecreases(before: Set<string>, after: Set<string>): Violation[];
|
|
11
|
+
//# sourceMappingURL=baseline.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debt baseline is allowed to shrink (pay off debt) but never grow: a PR
|
|
3
|
+
* must not introduce a debt id that wasn't already in the baseline it's
|
|
4
|
+
* diffing against. Content edits to *existing* ids (e.g. clarifying the
|
|
5
|
+
* `reality` text) are intentionally not checked here — judging whether a
|
|
6
|
+
* wording change "扩大了既有债务" is a semantic call, not a mechanical
|
|
7
|
+
* one, and belongs to human review, not T0.
|
|
8
|
+
*/
|
|
9
|
+
export function checkBaselineOnlyDecreases(before, after) {
|
|
10
|
+
const added = [...after].filter((id) => !before.has(id));
|
|
11
|
+
return added.map((id) => ({
|
|
12
|
+
check: "baseline-growth",
|
|
13
|
+
severity: "error",
|
|
14
|
+
message: `debt baseline grew: "${id}" is new and was not in the prior baseline; new debt must be paid down, not registered as baseline`,
|
|
15
|
+
nodeId: id,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=baseline.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LoadResult } from "../loader/load-model.js";
|
|
2
|
+
import { type ModelGraph } from "../loader/model-graph.js";
|
|
3
|
+
import type { Violation } from "./types.js";
|
|
4
|
+
/** Schema-parse failures collected during loadModel become blocking violations. */
|
|
5
|
+
export declare function checkSchema(load: LoadResult): Violation[];
|
|
6
|
+
/**
|
|
7
|
+
* Advisory: a file-per-node file's name (minus extension) must equal the id of
|
|
8
|
+
* the single node it holds (Decision 004 技术点 3 — promised for Phase 1,
|
|
9
|
+
* delivered here in A7). Only single-node files are checked; array files that
|
|
10
|
+
* group many nodes under a section name (loops/*.yaml) carry the section, not
|
|
11
|
+
* an id, and are correctly exempt (see LoadResult.singleNodeFiles). Warning,
|
|
12
|
+
* not error: a mismatch is a naming-hygiene slip a human should fix, not a
|
|
13
|
+
* reason to fail a build (the model still loads correctly).
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkFilenameMatchesId(load: LoadResult): Violation[];
|
|
16
|
+
/** Duplicate ids collected during loadModel become blocking violations. */
|
|
17
|
+
export declare function checkIdUniqueness(load: LoadResult): Violation[];
|
|
18
|
+
/** Blocking: every anchor must be syntactically well-formed (file#symbol or table[.column]). */
|
|
19
|
+
export declare function checkAnchorFormat(graph: ModelGraph): Violation[];
|
|
20
|
+
/**
|
|
21
|
+
* Blocking: every cross-node reference (Flow.traverses, Junction.between,
|
|
22
|
+
* Loop.parent, etc.) must resolve to an actual node of the expected kind
|
|
23
|
+
* in the loaded graph. This is pure set-membership over an already-loaded
|
|
24
|
+
* graph — no I/O, no AST — so it stays within T0's cost budget despite
|
|
25
|
+
* catching what would otherwise be a silent dangling/typo'd reference
|
|
26
|
+
* (e.g. a Flow.traverses entry that matches the LoopId regex shape but
|
|
27
|
+
* names a Loop that was never actually defined).
|
|
28
|
+
*/
|
|
29
|
+
export declare function checkReferentialIntegrity(graph: ModelGraph): Violation[];
|
|
30
|
+
/**
|
|
31
|
+
* Blocking: neither `Loop.parent` nor `Flow.references` may form a
|
|
32
|
+
* cycle. These are the only two same-kind reference fields in the
|
|
33
|
+
* schema; every other reference field (Flow.traverses/guarded_by/
|
|
34
|
+
* crosses, Junction.between/scenarios, Loop.scenarios, Feature.contains)
|
|
35
|
+
* is a bipartite edge into a *different* kind, and that target kind
|
|
36
|
+
* carries no reference field pointing back to the source kind (Scenario
|
|
37
|
+
* has no node-reference outputs at all; Loop's only outputs are `parent`
|
|
38
|
+
* and `scenarios`; Junction's only outputs are `between` and
|
|
39
|
+
* `scenarios`) — so no cross-kind cycle is reachable through the current
|
|
40
|
+
* schema. This is a claim about today's schema shape, not a structural
|
|
41
|
+
* guarantee: adding any new node-reference field (same-kind OR
|
|
42
|
+
* cross-kind) invalidates it and requires re-deriving which edges can
|
|
43
|
+
* now close a cycle.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkGraphAcyclic(graph: ModelGraph): Violation[];
|
|
46
|
+
/**
|
|
47
|
+
* Advisory by default: does the anchored file exist under `repoRoot`?
|
|
48
|
+
* This deliberately does NOT check the symbol resolves inside the file —
|
|
49
|
+
* that needs real AST parsing (Phase 2 adapter work). It also does not
|
|
50
|
+
* fail the build by default (severity: "warning") because a target repo's
|
|
51
|
+
* HEAD legitimately moves past whatever commit the model was authored
|
|
52
|
+
* against; treat failures here as "investigate: mis-encoded vs. code
|
|
53
|
+
* moved on", not as a hard gate. Pass `strict: true` to promote to error
|
|
54
|
+
* once a repo pins anchors to a known-good commit.
|
|
55
|
+
*/
|
|
56
|
+
export declare function checkAnchorExistence(graph: ModelGraph, repoRoot: string, options?: {
|
|
57
|
+
strict?: boolean | undefined;
|
|
58
|
+
}): Promise<Violation[]>;
|
|
59
|
+
//# sourceMappingURL=checks.d.ts.map
|