gasp-standard-mcp 1.9.0 → 1.11.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/README.md +30 -21
- package/build/compile.d.ts +21 -0
- package/build/compile.js +227 -0
- package/build/derive.d.ts +17 -0
- package/build/derive.js +261 -0
- package/build/formula.d.ts +158 -0
- package/build/formula.js +607 -0
- package/build/index.js +120 -307
- package/build/loader.d.ts +24 -3
- package/build/loader.js +141 -32
- package/build/parser.js +11 -0
- package/build/standard-data.json +1 -1
- package/build/standard-version.json +1 -1
- package/build/tools.d.ts +47 -0
- package/build/tools.js +270 -0
- package/build/traversal.js +4 -3
- package/build/types.d.ts +82 -1
- package/build/urls.d.ts +20 -0
- package/build/urls.js +31 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gasp-standard-mcp
|
|
2
2
|
|
|
3
|
-
MCP server for the [GASP Standard](https://gaspwiki.com)
|
|
3
|
+
MCP server for the [GASP Standard](https://gaspwiki.com), the Generally Accepted SaaS Principles standard for metric definitions, formulas, benchmarks and relationships.
|
|
4
4
|
|
|
5
5
|
Works with Claude Code, Claude Desktop, or any [MCP](https://modelcontextprotocol.io/)-compatible client.
|
|
6
6
|
|
|
@@ -33,35 +33,42 @@ Restart your MCP client after adding the config.
|
|
|
33
33
|
|
|
34
34
|
| Tool | Description |
|
|
35
35
|
|------|-------------|
|
|
36
|
-
| `lookup_metric` | Full metric details: definition, formula, benchmarks, entity, common mistakes, sources |
|
|
36
|
+
| `lookup_metric` | Full metric details: definition, formula, benchmarks, entity, common mistakes, sources and page URL. Matches exactly (name, abbreviation or slug) and suggests close names instead of guessing. Falls back to glossary terms when no metric matches |
|
|
37
|
+
| `lookup_definition` | Glossary term lookup (e.g. Signal Type, Sensitivity Tier, Access Scope, Metric Owner, CEL): full definition, section, canonical or reference link and page URL. Matches exactly and suggests close terms instead of guessing |
|
|
37
38
|
| `list_metrics` | List all metrics, optionally filtered by department and/or signal type |
|
|
38
|
-
| `get_formula` |
|
|
39
|
-
| `get_relationships` | Upstream/downstream relationships for metrics, entities
|
|
40
|
-
| `get_data_requirements` | Data requirements for implementing a metric: fields, types, grain, source
|
|
41
|
-
| `search` | Full-text search across metrics, definitions
|
|
42
|
-
| `generate_query` |
|
|
43
|
-
| `validate_adapter` | Validate a GASP adapter mapping file
|
|
44
|
-
| `infer_drivers` | Diagnose what could explain an observed metric movement
|
|
39
|
+
| `get_formula` | Canonical formula in the GASP formula grammar, with unit, O/M forms, variants, inputs with polarity, raw fields and the datasets they live in. Metrics that cannot be computed carry the reason |
|
|
40
|
+
| `get_relationships` | Upstream/downstream relationships for metrics, entities or concepts. 21 relationship types across signal, lifecycle, ownership, interaction and financial flow categories |
|
|
41
|
+
| `get_data_requirements` | Data requirements for implementing a metric: fields, types, grain, source and example SQL |
|
|
42
|
+
| `search` | Full-text search across metrics, definitions and data requirements. Metric and definition hits include their gaspwiki.com URL |
|
|
43
|
+
| `generate_query` | Compile warehouse-specific SQL from the metric's canonical formula and an adapter mapping. One scalar subquery per aggregate, each reading the table mapped for its dataset, with `:period_start` and `:period_end` parameters. Matches the metric exactly and suggests close names instead of guessing |
|
|
44
|
+
| `validate_adapter` | Validate a GASP adapter mapping file (schema 1.1). Reports schema-level problems, unknown fields and datasets, dataset table conflicts, enum fields mapped without a transform and covered metrics without an owner. Computes coverage per metric against the fields its formula needs (full, partial or none) and ranks the highest-value next fields to map |
|
|
45
|
+
| `infer_drivers` | Diagnose what could explain an observed metric movement: ranked upstream candidates with paths and per-step edge classifications |
|
|
45
46
|
| `explain_path` | Enumerate edge chains between two metrics, with classification (metric_signal / structural / lifecycle / behavioral / assignment / generative) per step |
|
|
46
47
|
| `coverage_gap` | Audit a metric set: identify one-hop upstream/downstream metrics not in the set, plus any disconnected metrics |
|
|
47
48
|
|
|
48
|
-
The traversal tools (`infer_drivers`, `explain_path`, `coverage_gap`) return *structure*
|
|
49
|
+
The traversal tools (`infer_drivers`, `explain_path`, `coverage_gap`) return *structure* (paths, classifications, attenuation scores), not numeric forecasts. They serve diagnosis, exploration and coverage-audit workflows respectively and compose: use `infer_drivers` to pick candidates, `explain_path` to scrutinise a chain, then `coverage_gap` to check whether the candidate is being measured. For counterfactual "what if" simulation, use the [TWIN Modifier](https://gaspwiki.com/v1/twin-modifier).
|
|
49
50
|
|
|
50
51
|
## Data
|
|
51
52
|
|
|
53
|
+
Version 1.11.0.
|
|
54
|
+
|
|
52
55
|
- 300 metrics across 13 departments
|
|
53
56
|
- 92 glossary definitions (80 SaaS domain + 12 GASP framework)
|
|
54
|
-
- 82 relationship nodes (60 metrics + 15 entities + 7 concepts),
|
|
57
|
+
- 82 relationship nodes (60 metrics + 15 entities + 7 concepts), 176 edges
|
|
55
58
|
- 21 relationship types: metric signal (drives, reduces, predicts, component, measures), entity lifecycle (converts_to, parent_of, belongs_to), entity ownership (owns, grants, priced_by), entity interactions (manages, serves, engages_via, submits, represented_by, sources), entity financial flows (generates, produces, contributes_to, incurs)
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
59
|
+
- 270 data requirements, one per metric with a formula, compiled from the formula at load time: fields with types, datasets, grain and a compiled example query
|
|
60
|
+
- 270 query templates, one per metric with a formula, compiled from the formulas by the same step (no hand-written SQL)
|
|
61
|
+
- 15 business entities (Customer, Subscription, License, Lead, Opportunity, User, Ticket, Employee, Invoice, Partner, Project, Plan, Product, Campaign and Contact)
|
|
62
|
+
- 7 business concepts (Revenue, Bookings, COGS, Cohort, Pipeline, Segment and TCV)
|
|
63
|
+
- 251 canonical fields across 47 datasets (event, snapshot and fact tables with date and key fields, entities, CEL class and activation rules). Enum fields declare their allowed values
|
|
64
|
+
- 59 structured formulas with decomposed inputs (metric refs + field refs)
|
|
65
|
+
- Canonical formulas for every metric in the GASP formula grammar (`standard/formulas.json`): 289 entries covering all 300 metrics (268 with formulas, 19 marked not computable with a reason, 2 department-qualified ids for shared headings), with units, O/M forms and variants such as Adjusted NRR (served by `get_formula` and compiled by `generate_query` and `get_data_requirements`)
|
|
62
66
|
- Access control classifications for every graph metric (sensitivity tier + access scope)
|
|
63
67
|
- CEL `change_nature` field for revenue quality analysis (Adjusted NRR/GRR)
|
|
64
68
|
- Per-department cross-references (e.g. NRR under Customer Success, Gross Margin under Finance) now resolve to the full canonical content via slug lookup
|
|
69
|
+
- Every metric and definition result carries its gaspwiki.com page URL; definitions keep their full body and reference links
|
|
70
|
+
- Access classifications, structured formulas and entity mappings resolve for every graph metric, including cross-reference records (e.g. NRR under Customer Success, CAC under Marketing)
|
|
71
|
+
- Adapter schema 1.1 (`standard/gasp-adapter-schema.json`): a `datasets` map, per-dataset mappings (a mapping names its `dataset`), informational fields and `$schema`. A ready template ships at `gasp-wiki-frontend/public/downloads/gasp-adapter-template.json` and is tested against the schema
|
|
65
72
|
- Sources: the canonical [GASP Standard](https://gaspwiki.com) files
|
|
66
73
|
|
|
67
74
|
## Examples
|
|
@@ -82,11 +89,13 @@ Once installed, ask your AI assistant:
|
|
|
82
89
|
- "Search for churn"
|
|
83
90
|
|
|
84
91
|
**Diagnosis, exploration and coverage** (new in 1.9.0)
|
|
85
|
-
- "ARR is down this month. What could explain it?"
|
|
86
|
-
- "How does
|
|
87
|
-
- "We track ARR, NRR, GRR, CAC and CAC Payback. What important metrics are we missing?"
|
|
88
|
-
- "Why might NRR be slipping? Trace the path back through Health Score."
|
|
92
|
+
- "ARR is down this month. What could explain it?" (`infer_drivers`)
|
|
93
|
+
- "How does Health Score eventually affect NRR?" (`explain_path`)
|
|
94
|
+
- "We track ARR, NRR, GRR, CAC and CAC Payback. What important metrics are we missing?" (`coverage_gap`)
|
|
95
|
+
- "Why might NRR be slipping? Trace the path back through Health Score." (composes `infer_drivers` and `explain_path`)
|
|
89
96
|
|
|
90
97
|
## License
|
|
91
98
|
|
|
99
|
+
<!-- TODO: add a "Source" link and package.json "repository" field to https://github.com/403pagelabs/gasp-wiki once the repository is public. -->
|
|
100
|
+
|
|
92
101
|
Apache-2.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MetricFormula, OntologyDataset, OntologyField } from "./types.js";
|
|
2
|
+
export interface CompileContext {
|
|
3
|
+
fields: Record<string, OntologyField>;
|
|
4
|
+
datasets: Record<string, OntologyDataset>;
|
|
5
|
+
formulas: Record<string, MetricFormula>;
|
|
6
|
+
}
|
|
7
|
+
export interface Compiled {
|
|
8
|
+
/** Scalar SELECT with placeholders */
|
|
9
|
+
sql: string;
|
|
10
|
+
/** Every field placeholder the SQL uses, including through metric refs */
|
|
11
|
+
fields: string[];
|
|
12
|
+
datasets: string[];
|
|
13
|
+
metricRefs: string[];
|
|
14
|
+
/** Uses cohort or lag, so it cannot run as one Ossie aggregate */
|
|
15
|
+
staged: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare class CompileError extends Error {
|
|
18
|
+
}
|
|
19
|
+
export declare function compileMetric(id: string, ctx: CompileContext, variant?: string): Compiled;
|
|
20
|
+
/** Replace placeholders with plain names, for documentation examples. */
|
|
21
|
+
export declare function exampleSql(sql: string): string;
|
package/build/compile.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles GASP formulas (src/formula.ts) to parameterised ANSI SQL.
|
|
3
|
+
*
|
|
4
|
+
* Every aggregate becomes a scalar subquery over one dataset, so metrics that
|
|
5
|
+
* combine several datasets need no joins: each aggregate is a number per
|
|
6
|
+
* period. Placeholders:
|
|
7
|
+
* {{<field>}} a canonical field; the adapter maps it to a column
|
|
8
|
+
* {{_table:<dataset>}} the warehouse table behind a dataset
|
|
9
|
+
* {{_period_start}}, {{_period_end}} reporting period bounds (dates)
|
|
10
|
+
*
|
|
11
|
+
* Period semantics by dataset kind: event and fact rows are filtered to the
|
|
12
|
+
* period by the dataset's date field; snapshot rows are taken as of the period
|
|
13
|
+
* end, or the point named by `at`. `to_date` widens an event or fact filter to
|
|
14
|
+
* everything up to the period end. `lag` shifts both bounds. `over cohort`
|
|
15
|
+
* restricts every aggregate to keys present in the key's dataset at the point.
|
|
16
|
+
*/
|
|
17
|
+
import { parseFormula } from "./formula.js";
|
|
18
|
+
export class CompileError extends Error {
|
|
19
|
+
}
|
|
20
|
+
const TEMPORAL = new Set(["date", "timestamp"]);
|
|
21
|
+
export function compileMetric(id, ctx, variant) {
|
|
22
|
+
const f = ctx.formulas[id];
|
|
23
|
+
if (!f)
|
|
24
|
+
throw new CompileError(`No formula for '${id}'`);
|
|
25
|
+
const src = variant ? f.forms?.[variant] ?? f.variants?.[variant] : f.formula;
|
|
26
|
+
if (!src)
|
|
27
|
+
throw new CompileError(f.notComputable ? `'${id}' is not computable: ${f.notComputable}` : `'${id}' has no ${variant ?? "formula"}`);
|
|
28
|
+
const c = new Compiler(ctx);
|
|
29
|
+
const body = c.formula(parseFormula(src), { toDate: false, shift: [], path: [id] });
|
|
30
|
+
return {
|
|
31
|
+
sql: `SELECT ${body} AS ${id.replace(/[^a-z0-9_]/gi, "_")}`,
|
|
32
|
+
fields: [...c.fields].sort(),
|
|
33
|
+
datasets: [...c.datasets].sort(),
|
|
34
|
+
metricRefs: [...c.metricRefs].sort(),
|
|
35
|
+
staged: c.staged,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Replace placeholders with plain names, for documentation examples. */
|
|
39
|
+
export function exampleSql(sql) {
|
|
40
|
+
return sql
|
|
41
|
+
.replace(/\{\{_table:(\w+)\}\}/g, "$1")
|
|
42
|
+
.replace(/\{\{_period_start\}\}/g, ":period_start")
|
|
43
|
+
.replace(/\{\{_period_end\}\}/g, ":period_end")
|
|
44
|
+
.replace(/\{\{(\w+)\}\}/g, "$1");
|
|
45
|
+
}
|
|
46
|
+
class Compiler {
|
|
47
|
+
ctx;
|
|
48
|
+
fields = new Set();
|
|
49
|
+
datasets = new Set();
|
|
50
|
+
metricRefs = new Set();
|
|
51
|
+
staged = false;
|
|
52
|
+
constructor(ctx) {
|
|
53
|
+
this.ctx = ctx;
|
|
54
|
+
}
|
|
55
|
+
formula(f, scope) {
|
|
56
|
+
const s = f.cohort ? { ...scope, cohort: scope.cohort ?? f.cohort } : scope;
|
|
57
|
+
if (f.cohort) {
|
|
58
|
+
this.staged = true;
|
|
59
|
+
this.fields.add(f.cohort.key);
|
|
60
|
+
}
|
|
61
|
+
return this.expr(f.expr, s);
|
|
62
|
+
}
|
|
63
|
+
bound(point, scope) {
|
|
64
|
+
let b = `{{_period_${point === "period_start" ? "start" : "end"}}}`;
|
|
65
|
+
for (const sh of scope.shift)
|
|
66
|
+
b = `(${b} - INTERVAL '${sh.n} ${sh.unit}')`;
|
|
67
|
+
return b;
|
|
68
|
+
}
|
|
69
|
+
type(id) {
|
|
70
|
+
return this.ctx.fields[id]?.type;
|
|
71
|
+
}
|
|
72
|
+
isTemporal(n) {
|
|
73
|
+
if (n.kind === "field")
|
|
74
|
+
return TEMPORAL.has(this.type(n.id) ?? "");
|
|
75
|
+
if (n.kind === "period_bound")
|
|
76
|
+
return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
expr(n, scope) {
|
|
80
|
+
switch (n.kind) {
|
|
81
|
+
case "number": return String(n.value);
|
|
82
|
+
case "string": return `'${n.value.replace(/'/g, "''")}'`;
|
|
83
|
+
case "boolean": return n.value ? "TRUE" : "FALSE";
|
|
84
|
+
case "field":
|
|
85
|
+
this.fields.add(n.id);
|
|
86
|
+
return `{{${n.id}}}`;
|
|
87
|
+
case "period_bound": return this.bound(n.point, scope);
|
|
88
|
+
case "period_days": return `(${this.bound("period_end", scope)} - ${this.bound("period_start", scope)} + 1)`;
|
|
89
|
+
case "metric": return this.metric(n.id, scope);
|
|
90
|
+
case "neg": return `(-${this.expr(n.arg, scope)})`;
|
|
91
|
+
case "binary": {
|
|
92
|
+
const l = this.expr(n.left, scope);
|
|
93
|
+
const r = this.expr(n.right, scope);
|
|
94
|
+
if (n.op === "-" && this.isTemporal(n.left) && this.isTemporal(n.right)) {
|
|
95
|
+
const ts = [n.left, n.right].some((x) => x.kind === "field" && this.type(x.id) === "timestamp");
|
|
96
|
+
return ts ? `(EXTRACT(EPOCH FROM (${l} - ${r})) / 86400.0)` : `(${l} - ${r})`;
|
|
97
|
+
}
|
|
98
|
+
if (n.op === "/")
|
|
99
|
+
return n.right.kind === "number" ? `(${l} / ${r})` : `(${l} / NULLIF(${r}, 0))`;
|
|
100
|
+
return `(${l} ${n.op} ${r})`;
|
|
101
|
+
}
|
|
102
|
+
case "scalar": {
|
|
103
|
+
const fn = { min: "LEAST", max: "GREATEST", abs: "ABS", coalesce: "COALESCE" }[n.fn];
|
|
104
|
+
return `${fn}(${n.args.map((a) => this.expr(a, scope)).join(", ")})`;
|
|
105
|
+
}
|
|
106
|
+
case "compare": return `${this.expr(n.left, scope)} ${n.op === "!=" ? "<>" : n.op} ${this.expr(n.right, scope)}`;
|
|
107
|
+
case "in": return `${this.expr(n.arg, scope)} ${n.negated ? "NOT IN" : "IN"} (${n.values.map((v) => this.expr(v, scope)).join(", ")})`;
|
|
108
|
+
case "isnull": return `${this.expr(n.arg, scope)} IS ${n.negated ? "NOT " : ""}NULL`;
|
|
109
|
+
case "logic": return `(${this.expr(n.left, scope)} ${n.op.toUpperCase()} ${this.expr(n.right, scope)})`;
|
|
110
|
+
case "not": return `NOT (${this.expr(n.arg, scope)})`;
|
|
111
|
+
case "at": return this.expr(n.arg, { ...scope, point: scope.point ?? n.point }); // outer at wins
|
|
112
|
+
case "to_date": return this.expr(n.arg, { ...scope, toDate: true });
|
|
113
|
+
case "lag":
|
|
114
|
+
this.staged = true;
|
|
115
|
+
return this.expr(n.arg, { ...scope, shift: [...scope.shift, { n: n.n, unit: n.unit }] });
|
|
116
|
+
case "agg":
|
|
117
|
+
case "percentile": return this.aggregate(n, scope);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
metric(id, scope) {
|
|
121
|
+
if (scope.path.includes(id))
|
|
122
|
+
throw new CompileError(`Cycle: ${[...scope.path, id].join(" -> ")}`);
|
|
123
|
+
const f = this.ctx.formulas[id];
|
|
124
|
+
if (!f?.formula)
|
|
125
|
+
throw new CompileError(`metric.${id} has no formula`);
|
|
126
|
+
this.metricRefs.add(id);
|
|
127
|
+
const inner = new Compiler(this.ctx);
|
|
128
|
+
const body = inner.formula(parseFormula(f.formula), { ...scope, path: [...scope.path, id] });
|
|
129
|
+
inner.fields.forEach((x) => this.fields.add(x));
|
|
130
|
+
inner.datasets.forEach((x) => this.datasets.add(x));
|
|
131
|
+
inner.metricRefs.forEach((x) => this.metricRefs.add(x));
|
|
132
|
+
this.staged ||= inner.staged;
|
|
133
|
+
return `(${body})`;
|
|
134
|
+
}
|
|
135
|
+
fieldsIn(n, out = new Set()) {
|
|
136
|
+
switch (n.kind) {
|
|
137
|
+
case "field":
|
|
138
|
+
out.add(n.id);
|
|
139
|
+
break;
|
|
140
|
+
case "binary":
|
|
141
|
+
case "logic":
|
|
142
|
+
case "compare":
|
|
143
|
+
this.fieldsIn(n.left, out);
|
|
144
|
+
this.fieldsIn(n.right, out);
|
|
145
|
+
break;
|
|
146
|
+
case "in":
|
|
147
|
+
this.fieldsIn(n.arg, out);
|
|
148
|
+
n.values.forEach((v) => this.fieldsIn(v, out));
|
|
149
|
+
break;
|
|
150
|
+
case "scalar":
|
|
151
|
+
n.args.forEach((a) => this.fieldsIn(a, out));
|
|
152
|
+
break;
|
|
153
|
+
case "neg":
|
|
154
|
+
case "lag":
|
|
155
|
+
case "at":
|
|
156
|
+
case "to_date":
|
|
157
|
+
case "not":
|
|
158
|
+
case "isnull":
|
|
159
|
+
this.fieldsIn(n.arg, out);
|
|
160
|
+
break;
|
|
161
|
+
case "agg":
|
|
162
|
+
case "percentile":
|
|
163
|
+
this.fieldsIn(n.arg, out);
|
|
164
|
+
if (n.where)
|
|
165
|
+
this.fieldsIn(n.where, out);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
datasetFor(fieldIds) {
|
|
171
|
+
const sets = fieldIds.map((id) => {
|
|
172
|
+
const f = this.ctx.fields[id];
|
|
173
|
+
if (!f)
|
|
174
|
+
throw new CompileError(`Unknown field '${id}'`);
|
|
175
|
+
return f.datasets ?? [];
|
|
176
|
+
});
|
|
177
|
+
const shared = sets.reduce((a, b) => a.filter((x) => b.includes(x)));
|
|
178
|
+
if (shared.length === 0)
|
|
179
|
+
throw new CompileError(`Fields share no dataset: ${fieldIds.join(", ")}`);
|
|
180
|
+
// Prefer the dataset keyed by one of the fields, then the first declared.
|
|
181
|
+
return shared.find((d) => fieldIds.includes(this.ctx.datasets[d]?.key)) ?? shared[0];
|
|
182
|
+
}
|
|
183
|
+
aggregate(n, scope) {
|
|
184
|
+
const ids = [...this.fieldsIn(n)];
|
|
185
|
+
const ds = this.datasetFor(ids);
|
|
186
|
+
const meta = this.ctx.datasets[ds];
|
|
187
|
+
if (!meta)
|
|
188
|
+
throw new CompileError(`Unknown dataset '${ds}'`);
|
|
189
|
+
this.datasets.add(ds);
|
|
190
|
+
const inner = this.expr(n.arg, scope);
|
|
191
|
+
const agg = n.kind === "percentile" ? `PERCENTILE_CONT(${n.p}) WITHIN GROUP (ORDER BY ${inner})`
|
|
192
|
+
: n.fn === "count_distinct" ? `COUNT(DISTINCT ${inner})`
|
|
193
|
+
: n.fn === "median" ? `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ${inner})`
|
|
194
|
+
: `${n.fn.toUpperCase()}(${inner})`;
|
|
195
|
+
const where = [];
|
|
196
|
+
const date = `{{${meta.date}}}`;
|
|
197
|
+
this.fields.add(meta.date);
|
|
198
|
+
if (meta.kind === "snapshot") {
|
|
199
|
+
where.push(`${date} = ${this.bound(scope.point ?? "period_end", scope)}`);
|
|
200
|
+
}
|
|
201
|
+
else if (scope.toDate) {
|
|
202
|
+
where.push(`${date} <= ${this.bound(scope.point ?? "period_end", scope)}`);
|
|
203
|
+
}
|
|
204
|
+
else if (scope.point) {
|
|
205
|
+
// A point on an event or fact dataset reads as everything up to that point.
|
|
206
|
+
where.push(`${date} <= ${this.bound(scope.point, scope)}`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
where.push(`${date} >= ${this.bound("period_start", scope)}`, `${date} <= ${this.bound("period_end", scope)}`);
|
|
210
|
+
}
|
|
211
|
+
if (n.where)
|
|
212
|
+
where.push(this.expr(n.where, scope));
|
|
213
|
+
if (scope.cohort && (this.ctx.fields[scope.cohort.key]?.datasets ?? []).includes(ds)) {
|
|
214
|
+
const key = scope.cohort.key;
|
|
215
|
+
this.fields.add(key);
|
|
216
|
+
const keyDs = Object.entries(this.ctx.datasets).find(([, d]) => d.key === key)?.[0];
|
|
217
|
+
if (!keyDs)
|
|
218
|
+
throw new CompileError(`Cohort key '${key}' is not the key of any dataset`);
|
|
219
|
+
this.datasets.add(keyDs);
|
|
220
|
+
const kmeta = this.ctx.datasets[keyDs];
|
|
221
|
+
this.fields.add(kmeta.date);
|
|
222
|
+
const active = kmeta.activeWhen ? ` AND ${this.expr(parseFormula(kmeta.activeWhen).expr, scope)}` : "";
|
|
223
|
+
where.push(`{{${key}}} IN (SELECT {{${key}}} FROM {{_table:${keyDs}}} WHERE {{${kmeta.date}}} = ${this.bound(scope.cohort.point, { ...scope, shift: [] })}${active})`);
|
|
224
|
+
}
|
|
225
|
+
return `(SELECT ${agg} FROM {{_table:${ds}}} WHERE ${where.join(" AND ")})`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { StandardData } from "./types.js";
|
|
3
|
+
export declare const DERIVED_FILES: {
|
|
4
|
+
ontology: string;
|
|
5
|
+
relationships: string;
|
|
6
|
+
cypher: string;
|
|
7
|
+
template: string;
|
|
8
|
+
};
|
|
9
|
+
/** Replace one top-level key's value in a JSON document without reformatting the rest. */
|
|
10
|
+
export declare function replaceJsonSection(raw: string, key: string, value: unknown): string;
|
|
11
|
+
export declare function computeDerived(data: StandardData, current: Record<keyof typeof DERIVED_FILES, string>): {
|
|
12
|
+
ontology: string;
|
|
13
|
+
relationships: string;
|
|
14
|
+
cypher: string;
|
|
15
|
+
template: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function readCurrent(): Record<keyof typeof DERIVED_FILES, string>;
|
package/build/derive.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Regenerates everything that derives from standard/formulas.json so no
|
|
4
|
+
* hand-maintained copy can drift from the canon:
|
|
5
|
+
*
|
|
6
|
+
* standard/ontology.json structuredFormulas, entityMappings, field metricCount
|
|
7
|
+
* gasp-wiki-frontend/src/data/relationships.json component and measures edges
|
|
8
|
+
* gasp-wiki-frontend/public/downloads/gasp-knowledge-graph.cypher whole file
|
|
9
|
+
* gasp-wiki-frontend/public/downloads/gasp-adapter-template.json whole file
|
|
10
|
+
*
|
|
11
|
+
* Usage: npm run derive write the files
|
|
12
|
+
* npm run derive:check exit 1 if any file differs (used by tests)
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { loadStandard, metricFormulaId, isMetricStub } from "./loader.js";
|
|
17
|
+
import { parseFormula, analyzeFormula } from "./formula.js";
|
|
18
|
+
import { compileMetric } from "./compile.js";
|
|
19
|
+
import { metricUrl } from "./urls.js";
|
|
20
|
+
const root = join(new URL(".", import.meta.url).pathname, "..", "..");
|
|
21
|
+
export const DERIVED_FILES = {
|
|
22
|
+
ontology: join(root, "standard", "ontology.json"),
|
|
23
|
+
relationships: join(root, "gasp-wiki-frontend", "src", "data", "relationships.json"),
|
|
24
|
+
cypher: join(root, "gasp-wiki-frontend", "public", "downloads", "gasp-knowledge-graph.cypher"),
|
|
25
|
+
template: join(root, "gasp-wiki-frontend", "public", "downloads", "gasp-adapter-template.json"),
|
|
26
|
+
};
|
|
27
|
+
/** Replace one top-level key's value in a JSON document without reformatting the rest. */
|
|
28
|
+
export function replaceJsonSection(raw, key, value) {
|
|
29
|
+
const marker = `\n "${key}": `;
|
|
30
|
+
const start = raw.indexOf(marker);
|
|
31
|
+
if (start < 0)
|
|
32
|
+
throw new Error(`Section "${key}" not found`);
|
|
33
|
+
const valueStart = start + marker.length;
|
|
34
|
+
let depth = 0, inStr = false, i = valueStart;
|
|
35
|
+
for (; i < raw.length; i++) {
|
|
36
|
+
const c = raw[i];
|
|
37
|
+
if (inStr) {
|
|
38
|
+
if (c === "\\")
|
|
39
|
+
i++;
|
|
40
|
+
else if (c === '"')
|
|
41
|
+
inStr = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '"')
|
|
45
|
+
inStr = true;
|
|
46
|
+
else if (c === "{" || c === "[")
|
|
47
|
+
depth++;
|
|
48
|
+
else if (c === "}" || c === "]") {
|
|
49
|
+
depth--;
|
|
50
|
+
if (depth === 0) {
|
|
51
|
+
i++;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const body = JSON.stringify(value, null, 2).split("\n").map((l, idx) => (idx ? " " + l : l)).join("\n");
|
|
57
|
+
return raw.slice(0, valueStart) + body + raw.slice(i);
|
|
58
|
+
}
|
|
59
|
+
const q = (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
60
|
+
export function computeDerived(data, current) {
|
|
61
|
+
const ont = data.ontology;
|
|
62
|
+
const formulas = data.formulas;
|
|
63
|
+
const fields = Object.fromEntries(ont.fields.map((f) => [f.id, f]));
|
|
64
|
+
const ctx = { fields, datasets: ont.datasets, formulas };
|
|
65
|
+
const nodeIds = new Set(data.nodes.filter((n) => !n.nodeType || n.nodeType === "metric").map((n) => n.id));
|
|
66
|
+
const byId = new Map();
|
|
67
|
+
for (const m of data.metrics)
|
|
68
|
+
if (!isMetricStub(m))
|
|
69
|
+
byId.set(metricFormulaId(data, m), m);
|
|
70
|
+
const slugOf = (id) => byId.get(id)?.slug;
|
|
71
|
+
// ── per-formula analysis ────────────────────────────────────────────────
|
|
72
|
+
const exprsOf = (f) => [f.formula, ...Object.values(f.forms ?? {}), ...Object.values(f.variants ?? {})].filter(Boolean);
|
|
73
|
+
const usage = new Map();
|
|
74
|
+
const compiled = new Map();
|
|
75
|
+
for (const [id, f] of Object.entries(formulas)) {
|
|
76
|
+
for (const src of exprsOf(f))
|
|
77
|
+
for (const fid of analyzeFormula(parseFormula(src)).fieldRefs)
|
|
78
|
+
(usage.get(fid) ?? usage.set(fid, new Set()).get(fid)).add(id);
|
|
79
|
+
if (f.formula)
|
|
80
|
+
compiled.set(id, compileMetric(id, ctx));
|
|
81
|
+
}
|
|
82
|
+
// ── ontology: structuredFormulas, entityMappings, metricCount ─────────────
|
|
83
|
+
const structured = {
|
|
84
|
+
description: "Derived from formulas.json by `npm run derive` (gasp-wiki-mcp). Each graph metric: its canonical formula, the metrics it reads (with arithmetic role and polarity: how the metric moves when the input rises) and the fields it reads, for graph traversal.",
|
|
85
|
+
};
|
|
86
|
+
for (const id of [...nodeIds].sort()) {
|
|
87
|
+
const f = formulas[id];
|
|
88
|
+
if (!f?.formula)
|
|
89
|
+
continue;
|
|
90
|
+
const a = analyzeFormula(parseFormula(f.formula));
|
|
91
|
+
const entry = {
|
|
92
|
+
expression: f.formula,
|
|
93
|
+
inputs: a.metricRefs.map((ref) => ({ ref, role: a.metricRoles[ref], polarity: a.metricPolarity[ref].length > 1 ? "mixed" : a.metricPolarity[ref][0] })),
|
|
94
|
+
fields: compiled.get(id).fields,
|
|
95
|
+
};
|
|
96
|
+
if (f.variants?.adjusted)
|
|
97
|
+
entry.adjustedVariant = f.variants.adjusted;
|
|
98
|
+
structured[id] = entry;
|
|
99
|
+
}
|
|
100
|
+
const keyEntity = new Map();
|
|
101
|
+
for (const [dsId, ds] of Object.entries(ont.datasets))
|
|
102
|
+
for (const e of ds.entities ?? [])
|
|
103
|
+
if (ds.key.endsWith("_id"))
|
|
104
|
+
keyEntity.set(ds.key, e);
|
|
105
|
+
const entityMappings = Object.fromEntries(ont.entities.map((e) => [e.id, []]));
|
|
106
|
+
for (const [id, c] of compiled) {
|
|
107
|
+
const ents = new Set();
|
|
108
|
+
for (const d of c.datasets)
|
|
109
|
+
for (const e of ont.datasets[d].entities ?? [])
|
|
110
|
+
ents.add(e);
|
|
111
|
+
for (const fid of c.fields) {
|
|
112
|
+
const e = keyEntity.get(fid);
|
|
113
|
+
if (e)
|
|
114
|
+
ents.add(e);
|
|
115
|
+
}
|
|
116
|
+
for (const e of ents)
|
|
117
|
+
entityMappings[e]?.push(id);
|
|
118
|
+
}
|
|
119
|
+
for (const k of Object.keys(entityMappings))
|
|
120
|
+
entityMappings[k].sort();
|
|
121
|
+
const fieldsOut = ont.fields.map((f) => {
|
|
122
|
+
const { metricCount: _old, ...rest } = f;
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const k of ["id", "type", "datasets", "sources", "values", "dimension", "description"])
|
|
125
|
+
if (rest[k] !== undefined)
|
|
126
|
+
out[k] = rest[k];
|
|
127
|
+
out.metricCount = usage.get(f.id)?.size ?? 0;
|
|
128
|
+
return out;
|
|
129
|
+
});
|
|
130
|
+
let ontology = replaceJsonSection(current.ontology, "fields", fieldsOut);
|
|
131
|
+
ontology = replaceJsonSection(ontology, "entityMappings", entityMappings);
|
|
132
|
+
ontology = replaceJsonSection(ontology, "structuredFormulas", structured);
|
|
133
|
+
// ── relationships.json: component + measures edges ───────────────────────
|
|
134
|
+
const rel = JSON.parse(current.relationships);
|
|
135
|
+
const kept = rel.edges.filter((e) => e.type !== "component" && e.type !== "measures");
|
|
136
|
+
const component = [];
|
|
137
|
+
for (const id of [...nodeIds].sort()) {
|
|
138
|
+
const f = formulas[id];
|
|
139
|
+
if (!f?.formula)
|
|
140
|
+
continue;
|
|
141
|
+
for (const ref of analyzeFormula(parseFormula(f.formula)).metricRefs)
|
|
142
|
+
if (nodeIds.has(ref))
|
|
143
|
+
component.push({ source: ref, target: id, type: "component" });
|
|
144
|
+
}
|
|
145
|
+
const measures = [];
|
|
146
|
+
const entityNodes = new Set(data.nodes.filter((n) => n.nodeType === "entity").map((n) => n.id));
|
|
147
|
+
for (const [e, ids] of Object.entries(entityMappings))
|
|
148
|
+
for (const id of ids)
|
|
149
|
+
if (nodeIds.has(id) && entityNodes.has(`entity_${e}`))
|
|
150
|
+
measures.push({ source: id, target: `entity_${e}`, type: "measures" });
|
|
151
|
+
rel.edges = [...kept.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)), ...component, ...measures, ...kept.filter((e) => !(nodeIds.has(e.source) && nodeIds.has(e.target)))];
|
|
152
|
+
const relationships = JSON.stringify(rel, null, 2) + "\n";
|
|
153
|
+
// ── cypher ────────────────────────────────────────────────────────────────
|
|
154
|
+
const L = [];
|
|
155
|
+
const metricNodes = data.nodes.filter((n) => nodeIds.has(n.id));
|
|
156
|
+
const entityEdges = ont.entityRelationships?.edges ?? [];
|
|
157
|
+
const metricEdges = rel.edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
|
|
158
|
+
const temporal = JSON.parse(current.ontology);
|
|
159
|
+
const sourcedFrom = ont.fields.flatMap((f) => (f.sources ?? []).map((s) => [f.id, s]));
|
|
160
|
+
const belongs = ont.fields.flatMap((f) => (f.datasets ?? []).map((d) => [f.id, d]));
|
|
161
|
+
L.push("// ═══════════════════════════════════════════════════════════════", "// GASP Standard — SaaS Metric Knowledge Graph (Full Ontology)", "// Generated from the GASP Standard (https://gaspwiki.com) by `npm run derive`", "// ═══════════════════════════════════════════════════════════════", "//", `// Nodes: ${metricNodes.length} Metrics, ${ont.entities.length} Entities, ${ont.concepts.length} Concepts, ${Object.keys(ont.datasets).length} Datasets, ${ont.fields.length} Fields, ${ont.sourceCategories.length} Source Categories`, `// Edges: ${metricEdges.length} metric relationships, ${measures.length} MEASURES, ${entityEdges.length} entity/concept relationships, ${belongs.length} BELONGS_TO, ${sourcedFrom.length} SOURCED_FROM`, "//", "// Relationship types (metric signal): DRIVES, REDUCES, PREDICTS, COMPONENT_OF, MEASURES", "// Relationship types (entity): " + Object.values(ont.entityRelationships?.edgeTypeClassification ?? {}).flat().join(", "), "// Relationship types (data lineage): BELONGS_TO (Field → Dataset), SOURCED_FROM (Field → SourceCategory)", "//", "// Import: paste into Neo4j Browser or pipe to cypher-shell", "// ═══════════════════════════════════════════════════════════════", "", "// Clean slate (optional, uncomment to clear existing data)", "// MATCH (n) DETACH DELETE n;", "");
|
|
162
|
+
L.push(`// ─── Metric Nodes (${metricNodes.length}) ─────────────────────────`);
|
|
163
|
+
for (const n of metricNodes) {
|
|
164
|
+
const m = byId.get(n.id) ?? data.metrics.find((x) => metricFormulaId(data, x) === n.id);
|
|
165
|
+
const t = temporal.temporalSemantics?.[temporal.temporalMapping?.[n.id]] ?? {};
|
|
166
|
+
const props = [`id: ${q(n.id)}`, `name: ${q(n.label)}`, `department: ${q(n.department ?? m?.department ?? "")}`, `signalType: ${q(n.signalType ?? "")}`, `level: ${n.level ?? 0}`,
|
|
167
|
+
`definition: ${q(m?.definition ?? n.description ?? "")}`, `formula: ${q(formulas[n.id]?.formula ?? m?.formulas?.[0] ?? "")}`, `url: ${q(m ? metricUrl(m) : "")}`];
|
|
168
|
+
if (t.grain)
|
|
169
|
+
props.push(`grain: ${q(t.grain)}`, `frequency: ${q(t.frequency)}`, `period: ${q(t.period)}`);
|
|
170
|
+
L.push(`CREATE (:Metric {${props.join(", ")}});`);
|
|
171
|
+
}
|
|
172
|
+
L.push("", `// ─── Entity Nodes (${ont.entities.length}) ──────────────────────────`);
|
|
173
|
+
for (const e of ont.entities)
|
|
174
|
+
L.push(`CREATE (:Entity {id: ${q(e.id)}, name: ${q(e.label)}, aliases: ${q((e.aliases ?? []).join(", "))}, definition: ${q(e.definition)}, level: ${q(e.level ?? "")}});`);
|
|
175
|
+
L.push("", `// ─── Concept Nodes (${ont.concepts.length}) ───────────────────────`);
|
|
176
|
+
for (const c of ont.concepts)
|
|
177
|
+
L.push(`CREATE (:Concept {id: ${q(c.id)}, name: ${q(c.label)}, definition: ${q(c.definition)}});`);
|
|
178
|
+
L.push("", `// ─── Source Category Nodes (${ont.sourceCategories.length}) ──────────────`);
|
|
179
|
+
for (const s of ont.sourceCategories)
|
|
180
|
+
L.push(`CREATE (:SourceCategory {id: ${q(s.id)}, name: ${q(s.label ?? s.id)}, description: ${q(s.description ?? "")}});`);
|
|
181
|
+
L.push("", `// ─── Dataset Nodes (${Object.keys(ont.datasets).length}) ─────────────────────────────`);
|
|
182
|
+
for (const [id, d] of Object.entries(ont.datasets))
|
|
183
|
+
L.push(`CREATE (:Dataset {id: ${q(id)}, name: ${q(d.label)}, kind: ${q(d.kind)}, dateField: ${q(d.date)}, keyField: ${q(d.key)}${d.cel ? `, cel: ${q(d.cel)}` : ""}});`);
|
|
184
|
+
L.push("", `// ─── Field Nodes (${ont.fields.length}) ─────────────────────────────`);
|
|
185
|
+
for (const f of fieldsOut)
|
|
186
|
+
L.push(`CREATE (:Field {id: ${q(f.id)}, type: ${q(f.type)}, metricCount: ${f.metricCount}${f.values ? `, values: ${q(f.values.join("|"))}` : ""}});`);
|
|
187
|
+
L.push("", `// ─── Metric Relationships (${metricEdges.length}) ──────────────────`);
|
|
188
|
+
for (const e of metricEdges)
|
|
189
|
+
L.push(`MATCH (a:Metric {id: ${q(e.source)}}), (b:Metric {id: ${q(e.target)}})`, `CREATE (a)-[:${e.type === "component" ? "COMPONENT_OF" : String(e.type).toUpperCase()}]->(b);`);
|
|
190
|
+
L.push("", `// ─── MEASURES Edges (${measures.length}) ──────────────────────────`);
|
|
191
|
+
for (const e of measures)
|
|
192
|
+
L.push(`MATCH (m:Metric {id: ${q(e.source)}}), (e:Entity {id: ${q(e.target.replace("entity_", ""))}})`, `CREATE (m)-[:MEASURES]->(e);`);
|
|
193
|
+
L.push("", `// ─── BELONGS_TO Edges (${belongs.length}) ─────────────────────────────`);
|
|
194
|
+
for (const [f, d] of belongs)
|
|
195
|
+
L.push(`MATCH (f:Field {id: ${q(f)}}), (d:Dataset {id: ${q(d)}})`, `CREATE (f)-[:BELONGS_TO]->(d);`);
|
|
196
|
+
L.push("", `// ─── SOURCED_FROM Edges (${sourcedFrom.length}) ─────────────────────────────`);
|
|
197
|
+
for (const [f, s] of sourcedFrom)
|
|
198
|
+
L.push(`MATCH (f:Field {id: ${q(f)}}), (s:SourceCategory {id: ${q(s)}})`, `CREATE (f)-[:SOURCED_FROM]->(s);`);
|
|
199
|
+
const conceptIds = new Set(ont.concepts.map((c) => c.id));
|
|
200
|
+
const classOf = (type) => Object.entries(ont.entityRelationships?.edgeTypeClassification ?? {}).find(([, ts]) => ts.includes(type))?.[0] ?? "other";
|
|
201
|
+
L.push("", `// ─── Entity Relationships (${entityEdges.length}) ──────────────`);
|
|
202
|
+
for (const cls of [...new Set(entityEdges.map((e) => classOf(e.type)))]) {
|
|
203
|
+
L.push(`// ${cls[0].toUpperCase()}${cls.slice(1)}`);
|
|
204
|
+
for (const e of entityEdges.filter((x) => classOf(x.type) === cls)) {
|
|
205
|
+
const target = conceptIds.has(e.to) ? `(t:Concept {id: ${q(e.to)}})` : `(t:Entity {id: ${q(e.to)}})`;
|
|
206
|
+
L.push(`MATCH (s:Entity {id: ${q(e.from)}}), ${target}`, `CREATE (s)-[:${e.type} {cardinality: ${q(e.cardinality)}, provenance: ${q(e.provenance)}}]->(t);`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
L.push("", "// ─── Indexes ──────────────────────────────────────────", "CREATE INDEX metric_id IF NOT EXISTS FOR (m:Metric) ON (m.id);", "CREATE INDEX metric_dept IF NOT EXISTS FOR (m:Metric) ON (m.department);", "CREATE INDEX metric_signal IF NOT EXISTS FOR (m:Metric) ON (m.signalType);", "CREATE INDEX entity_id IF NOT EXISTS FOR (e:Entity) ON (e.id);", "CREATE INDEX concept_id IF NOT EXISTS FOR (c:Concept) ON (c.id);", "CREATE INDEX dataset_id IF NOT EXISTS FOR (d:Dataset) ON (d.id);", "CREATE INDEX field_id IF NOT EXISTS FOR (f:Field) ON (f.id);", "CREATE INDEX source_id IF NOT EXISTS FOR (s:SourceCategory) ON (s.id);", "");
|
|
210
|
+
const cypher = L.join("\n");
|
|
211
|
+
// ── adapter template ──────────────────────────────────────────────────────
|
|
212
|
+
const label = new Map(ont.sourceCategories.map((s) => [s.id, s.label ?? s.id]));
|
|
213
|
+
const template = {
|
|
214
|
+
$schema: "https://gaspwiki.com/schemas/gasp-adapter-v1.json",
|
|
215
|
+
version: "1.1",
|
|
216
|
+
organization: "",
|
|
217
|
+
warehouse: "",
|
|
218
|
+
_instructions: "Map a warehouse table to each dataset you have (schema.table) and a column to each field (a bare column name, or schema.table.column). Fields that live on several datasets appear once per dataset; fill the ones you have. Fields you cannot map can be left with an empty warehouseRef or removed. Enum fields list the values the column must produce after transform. Run validate_adapter (GASP MCP) to see which metrics you can compute and which owners are missing.",
|
|
219
|
+
metricOwners: [],
|
|
220
|
+
datasets: Object.fromEntries(Object.keys(ont.datasets).sort().map((d) => [d, ""])),
|
|
221
|
+
mappings: ont.fields.flatMap((f) => {
|
|
222
|
+
const usedBy = [...(usage.get(f.id) ?? [])].map((id) => byId.get(id)?.name ?? id).sort();
|
|
223
|
+
const row = (dataset) => {
|
|
224
|
+
const m = { gaspField: f.id };
|
|
225
|
+
if (dataset)
|
|
226
|
+
m.dataset = dataset;
|
|
227
|
+
m.type = f.type;
|
|
228
|
+
m.warehouseRef = "";
|
|
229
|
+
if (f.values)
|
|
230
|
+
m.values = f.values;
|
|
231
|
+
m.source = label.get(f.sources?.[0] ?? "") ?? "";
|
|
232
|
+
m.usedBy = usedBy;
|
|
233
|
+
return m;
|
|
234
|
+
};
|
|
235
|
+
return (f.datasets ?? []).length > 1 ? f.datasets.map((d) => row(d)) : [row()];
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
return { ontology, relationships, cypher, template: JSON.stringify(template, null, 2) + "\n" };
|
|
239
|
+
}
|
|
240
|
+
export function readCurrent() {
|
|
241
|
+
return Object.fromEntries(Object.entries(DERIVED_FILES).map(([k, p]) => [k, readFileSync(p, "utf-8")]));
|
|
242
|
+
}
|
|
243
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop().replace(/\.ts$/, ".js")) || process.argv[1]?.endsWith("derive.ts");
|
|
244
|
+
if (isMain) {
|
|
245
|
+
const check = process.argv.includes("--check");
|
|
246
|
+
const current = readCurrent();
|
|
247
|
+
const out = computeDerived(loadStandard(), current);
|
|
248
|
+
const changed = Object.keys(out).filter((k) => out[k] !== current[k]);
|
|
249
|
+
if (check) {
|
|
250
|
+
if (changed.length) {
|
|
251
|
+
console.error(`Derived files out of date: ${changed.join(", ")}. Run npm run derive.`);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
console.log("Derived files are current.");
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
for (const k of changed)
|
|
258
|
+
writeFileSync(DERIVED_FILES[k], out[k]);
|
|
259
|
+
console.log(changed.length ? `Updated ${changed.join(", ")}` : "No changes.");
|
|
260
|
+
}
|
|
261
|
+
}
|