Skip to content

Commit 31d23c3

Browse files
feat(cli): port the deterministic scenario scorer — shape score, artifact graph reader and Run grader — with a discrimination self-test (#30332)
Fixes #30290 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:把云端评测里的确定性打分器搬进 `packages/cli/src/lint`(三个新文件:结构打分 `scenario-score.ts`、构建产物转打分图 `scenario-graph.ts`、对话回合判分 `scenario-run-grade.ts`),各带测试;不调用任何模型,不加命令行参数,不加包入口导出,目前没有调用方。 - 为什么改:cloud#3600 D2 裁决「打分器可以迁移到开源项目」,场景库需要一个开源、纯代码的打分器;成本裁决「目前阶段尽量不要用云端的token,api的更贵」排除了模型评审。搬运时用 5 个黄金样例做「区分度自测」,实测原代码有两处会给坏构建打满分(导航指向不存在的对象、仪表盘组件绑定不存在的数据集),已在本 PR 修掉。 - 风险与回滚:风险低。只新增文件,没有任何现有代码引用它们,发布面只多出 `dist/lint/scenario-*` 三个模块(包入口不可达);回滚即删这三个文件和它们的测试。 - 先看:`packages/cli/src/lint/scenario-score.ts`(看顶部「与云端源码的差异」一段和 `scoreGraph` 的导航检查、`measureChecks`);`packages/cli/src/lint/scenario-graph.ts`(只按 spec 的唯一形状读产物,读不懂就抛错);`packages/cli/src/lint/scenario-score.test.ts`(区分度自测)。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run src/lint/scenario-score.test.ts src/lint/scenario-graph.test.ts src/lint/scenario-run-grade.test.ts`,预期 3 个文件、33 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Ported under the cloud#3600 D2 ruling from the cloud eval harness (`structural-score.ts`, the graph reader in `agent-e2e.ts`, and `ask-accuracy/scorer.ts`), each file with the Apache-2.0 header and one provenance line. No model call anywhere; no new CLI flag; no new package-entry export. - **`packages/cli/src/lint/scenario-score.ts`**: `ScenarioExpectation` (field-for-field the card's `expect` block, mirrored from the sibling contract and not imported), `EvalGraph`, `scoreGraph` (exact names), `ShapeExpectation`, `deriveShape` and `scoreGraphShape` (name-agnostic). The reference type set is the spec's `REFERENCE_VALUE_TYPES`. - **`packages/cli/src/lint/scenario-graph.ts`**: `artifactToEvalGraph(artifact)` reads a built `dist/objectstack.json` into the graph. Collections come through `resolveStackCollection` (`@objectstack/core`, already re-exported by `utils/stack-collections.ts`), the rule the CLI and `@objectstack/verify` share, so a multi-package artifact with its collections only under `packages[]` (ADR-0130 option B) reads the same as a single-package one. ⛔ No local walk of `packages[]`. Each key is read in its one spec shape (`fields` record, `reference` via `referenceTargetOf`, `navigation` + nested `children`, widget `dataset` + `values`); the cloud reader's alias chains (`navigation ?? nav ?? tabs ?? …`, `reference_to ?? referenceTo ?? reference`) are deliberately not carried over. An object with no name or a `fields` that is not a record throws a `TypeError` instead of being read as an empty (degraded) object. A fixed-target type (`user`) gets no `reference` in the graph; which types fix their target is asked of the spec (`referenceTargetOf({ type })`), not listed here. - **`packages/cli/src/lint/scenario-run-grade.ts`**: `gradeRun(truth, hidden, { replyText, toolCalls: [{ name, input, output }] })` returns `{ correct, cited, leaks, problems }`. The dataset and the hosted-chat transcript type are cut out: the caller passes the truth figures and the hidden scope (objects, fields, hidden values, hidden aggregates). A tool call answered over a hidden field is detected structurally (every key and string in the input), not through a list of tool parameter names, so a raw `sort` / `group_by` spelling is still seen. Records-in-order and refusal-wording grading belonged to the cloud dataset's question kinds and are not ported. ### Two deviations from the cloud source, both measured The discrimination self-test degrades each golden build one defect at a time and requires a strictly lower score. With the cloud semantics restored, two degradations score exactly as high as the intact build: 1. `nav:TARGET->exists` asked whether an object/dashboard/app of that name exists, not whether any app navigates to it. Now it passes only when an app nav entry targets the name and resolves, and every app nav entry is scored for resolving (`nav-resolves:TARGET`), as the shape scorer already did. 2. A widget bound to a dataset the graph does not carry passed its measure check (`!ds ||`). A local artifact is the whole build, so that binding dangles and now fails, in both scorers. Smaller: the field target key is `reference` (the spec's) rather than `reference_to`, and the platform-object prefix is `sys_` / `ai_` (no `cloud_`). ## Tests All readings at `c0a6cf98` (`git rev-parse --short HEAD`), the final commit, under `scripts/pm/os-verify-lock.sh` (shared-box seconds). - New suites: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/lint/scenario-score.test.ts src/lint/scenario-graph.test.ts src/lint/scenario-run-grade.test.ts` gave `Test Files 3 passed (3)` · `Tests 33 passed (33)`. - Package unit tier: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` gave `Test Files 387 passed (387)` · `Tests 5460 passed (5460)`. The `integration` tier is left to CI: the diff touches no integration file, spawn entry, or driver/kernel boot path. - `pnpm --filter @objectstack/cli typecheck` exited 0 (`tsc --noEmit`, then `check:test-typecheck: OK`). `eslint --no-inline-config packages/cli/src/lint/scenario-*.ts` exited 0. <details><summary>Discrimination self-test and the two ablations (one-time proof, not kept as tests)</summary> Every intact golden build scores 1 on both scorers. Each variant (missing lookup, optionless select, dangling nav target, dangling dashboard measure, dangling dashboard dataset) scores strictly lower on both `scoreGraph` and `scoreGraphShape`, over 5 builds (optionless select over 4). The cloud semantics were put back with `node scripts/ablation-replace.mjs` (the anchor must hit, and it restores to `blob == HEAD` with an empty `git diff HEAD`): 1. Nav check reverted to `targets.has(norm(navName))` with the per-entry `nav-resolves` loop removed. Result: 3 failed / 13 passed in `scenario-score.test.ts`. That includes `dangling nav target` with `invoice_with_line_items exact: expected 1 to be less than 1`, which means the degraded build scored the same 1.00 as the intact one. Restored: blob `eed52bc0d3ca` == HEAD. 2. Measure check reverted to `!ds ||`. Result: 1 failed / 15 passed, `dangling dashboard dataset` with `invoice_with_line_items exact: expected 1 to be less than 1`. Restored: blob `eed52bc0d3ca` == HEAD. </details> <details><summary>Clause-② measurement</summary> After `pnpm --filter @objectstack/cli build`, I walked the declaration closure of the three package entries (`dist/index.d.ts`, `dist/console.d.ts`, `dist/hook-body.d.ts`). It holds 39 declaration files, and 0 of them are under `dist/lint/`. Positive control: the same walker started at `dist/lint/scenario-graph.d.ts` finds 2 files under `dist/lint/`. Reading: `Clause-②: no`. </details> ## Acceptance notes - PM mechanism assumption on the artifact reader: the CLI already has the reader, `resolveStackCollection` (re-exported by `packages/cli/src/utils/stack-collections.ts`), and it is reused. `packages/verify/src/artifact-collections.ts`'s `declaredCollection` was not used: it answers a different question (a truthy top level wins, so a declared-empty array behaves differently) and the CLI does not reach into `@objectstack/verify` for a read it already owns. - PM mechanism assumption on the corpus: the 5 fixtures in `corpus.ts` carry objects only (no app, dataset or dashboard), so the self-test completes each with an app (nav over every object and the dashboard), one dataset and one dashboard, then parses the result with `ObjectStackDefinitionSchema` (the parse `os build` runs before writing the artifact). Every intact build scores 1 on both scorers; the reproduction of the "always 1.00" blind spot is the two ablations below, not the intact scores. - `blog_post_with_comments` declares no select, so the optionless-select variant runs on 4 of 5 builds; the test pins that count. - The two scorer blind spots also live in the cloud source this was ported from (`carrier: cloud`, reported to the seat, not filed here). ## Verification Gate list from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths: the script takes the change set from the merge-base). Each command ran at `c0a6cf98`, with its exit code captured before any pipe. `--ran` reconciliation: `59 derived famil(ies) accounted for — 57 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - 57 exited 0. `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 because its pinned positive-control commit was missing on a shallow checkout. After `git fetch --depth=1 origin 621a487` it exited 0. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (it reads `dist/` of 10 packages outside this diff's build closure). - NOT MEASURED: `check:i18n-coverage`, reason: PREREQUISITE NOT MET (the showcase config loads packages without build output, e.g. `@objectstack/service-print`). - Both are declared narrowings, and CI runs them on its full build. The diff touches neither dual-build output nor i18n. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q4EjjPXwX1YfHyKJUh4agu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a545a04 commit 31d23c3

8 files changed

Lines changed: 1313 additions & 0 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): a deterministic scenario scorer under `src/lint` — the structural shape score, an artifact-to-graph reader and a Run-half grader, ported from the cloud eval harness
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Structural scorer** (`src/lint/scenario-score.ts`): `scoreGraph` matches a scenario's `expect` block by exact name, `scoreGraphShape` scores the build's shape regardless of names, and `deriveShape` turns the first into the second. Two checks that let a degraded build score as high as an intact one are closed: an expected nav target now passes only when an app navigates to it through an entry that resolves (every app nav entry is also scored for resolving), and a dashboard widget bound to a dataset the build does not carry now fails its measure check.
11+
- **Artifact reader** (`src/lint/scenario-graph.ts`): reads a built `dist/objectstack.json` into the scorer's graph, single-package or with its collections only under `packages[]` (ADR-0130), through the shared `resolveStackCollection` rule, reading each key in its one spec shape and refusing an unreadable object instead of scoring it as an empty one.
12+
- **Run grader** (`src/lint/scenario-run-grade.ts`): grades one agent turn against a question's computed truth as `{ correct, cited, leaks, problems }` — figures matched (with `万` / `亿` / `k` / `M` read), each figure's object cited, and hidden values, hidden aggregates and tool calls answered over a hidden field or object reported as leaks, in the reply or in any tool output.
13+
14+
No model call anywhere, no new CLI flag and no new package-entry export: nothing calls these yet; the `os lint --eval` wiring is a later change.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
6+
7+
import { DEFAULT_METADATA_EVAL_CORPUS } from './corpus.js';
8+
import { artifactToEvalGraph } from './scenario-graph.js';
9+
10+
type Bag = Record<string, any>;
11+
12+
const manifest = { id: 'com.example.library', namespace: 'library', version: '1.0.0', name: 'Library', type: 'app' };
13+
14+
const objects = [
15+
{
16+
name: 'loan',
17+
label: 'Loan',
18+
fields: {
19+
book: { type: 'lookup', label: 'Book', reference: 'book' },
20+
// As `Field.user()` writes it: the carrier states the type's fixed target.
21+
borrower: { type: 'user', label: 'Borrower', reference: 'sys_user' },
22+
status: {
23+
type: 'select',
24+
label: 'Status',
25+
required: true,
26+
options: [{ label: 'Out', value: 'out' }, { label: 'Back', value: 'back' }],
27+
},
28+
},
29+
},
30+
{ name: 'book', label: 'Book', fields: { title: { type: 'text', label: 'Title', required: true } } },
31+
];
32+
const datasets = [{
33+
name: 'loan_metrics',
34+
label: 'Loan Metrics',
35+
object: 'loan',
36+
dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }],
37+
measures: [{ name: 'loan_count', label: 'Loans', aggregate: 'count' }],
38+
}];
39+
const dashboards = [{
40+
name: 'library_overview',
41+
label: 'Overview',
42+
widgets: [{ id: 'by_status', title: 'By status', type: 'bar', dataset: 'loan_metrics', dimensions: ['status'], values: ['loan_count'] }],
43+
}];
44+
const apps = [{
45+
name: 'library',
46+
label: 'Library',
47+
navigation: [
48+
{ id: 'nav_home', type: 'dashboard', label: 'Home', dashboardName: 'library_overview' },
49+
{
50+
id: 'nav_data',
51+
type: 'group',
52+
label: 'Data',
53+
children: [
54+
{ id: 'nav_loan', type: 'object', label: 'Loans', objectName: 'loan' },
55+
{ id: 'nav_book', type: 'object', label: 'Books', objectName: 'book' },
56+
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user' },
57+
{ id: 'nav_gone', type: 'object', label: 'Gone', objectName: 'member' },
58+
],
59+
},
60+
{ id: 'nav_docs', type: 'url', label: 'Docs', url: 'https://example.com' },
61+
],
62+
}];
63+
64+
const EXPECTED_GRAPH = {
65+
objects: [
66+
{
67+
name: 'loan',
68+
fields: [
69+
{ name: 'book', type: 'lookup', reference: 'book' },
70+
{ name: 'borrower', type: 'user' },
71+
{ name: 'status', type: 'select', required: true, options: [{ value: 'out', label: 'Out' }, { value: 'back', label: 'Back' }] },
72+
],
73+
},
74+
{ name: 'book', fields: [{ name: 'title', type: 'text', required: true }] },
75+
],
76+
dashboards: [{ name: 'library_overview', widgets: [{ dataset: 'loan_metrics', measure: 'loan_count' }] }],
77+
apps: [{
78+
name: 'library',
79+
nav: [
80+
{ target: 'library_overview', resolves: true },
81+
{ target: 'loan', resolves: true },
82+
{ target: 'book', resolves: true },
83+
{ target: 'sys_user', resolves: true },
84+
{ target: 'member', resolves: false },
85+
],
86+
}],
87+
datasets: [{ name: 'loan_metrics', measures: ['loan_count'] }],
88+
};
89+
90+
/** The stack parsed as `os build` parses it, round-tripped through JSON as the artifact file is. */
91+
function parsedArtifact(stack: Bag): Bag {
92+
const parsed = ObjectStackDefinitionSchema.safeParse(normalizeStackInput(stack));
93+
if (!parsed.success) throw new Error(parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));
94+
return JSON.parse(JSON.stringify(parsed.data)) as Bag;
95+
}
96+
97+
describe('artifactToEvalGraph', () => {
98+
it('reads a single-package artifact: fields, options, authored references, nested nav, widget measures', () => {
99+
const artifact = parsedArtifact({ manifest, objects, datasets, dashboards, apps });
100+
// The artifact carries `user`'s fixed target; the graph still leaves it out.
101+
expect(artifact.objects[0].fields.borrower.reference).toBe('sys_user');
102+
expect(artifactToEvalGraph(artifact)).toEqual(EXPECTED_GRAPH);
103+
});
104+
105+
it('reads a multi-package artifact whose collections live only under packages[] (ADR-0130 option B)', () => {
106+
const artifact = {
107+
manifest,
108+
packages: [
109+
{ manifest: { id: 'com.example.library.core', name: 'core', version: '1.0.0', type: 'app', objects } },
110+
{
111+
manifest: {
112+
id: 'com.example.library.ui',
113+
name: 'ui',
114+
version: '1.0.0',
115+
type: 'app',
116+
dependencies: { 'com.example.library.core': '1.0.0' },
117+
datasets,
118+
dashboards,
119+
apps,
120+
},
121+
},
122+
],
123+
};
124+
expect(artifact).not.toHaveProperty('objects');
125+
expect(artifactToEvalGraph(artifact)).toEqual(EXPECTED_GRAPH);
126+
});
127+
128+
it('prefers the top-level collection over packages[] when the artifact carries both (the additive shape)', () => {
129+
const artifact = {
130+
manifest,
131+
objects: [objects[1]],
132+
packages: [{ manifest: { id: 'com.example.library.core', name: 'core', version: '1.0.0', type: 'app', objects } }],
133+
};
134+
expect(artifactToEvalGraph(artifact).objects.map((o) => o.name)).toEqual(['book']);
135+
});
136+
137+
it('reads every golden corpus build', () => {
138+
for (const c of DEFAULT_METADATA_EVAL_CORPUS) {
139+
const graph = artifactToEvalGraph(parsedArtifact(c.fixture as Bag));
140+
expect(graph.objects.length, c.id).toBe((c.fixture as Bag).objects.length);
141+
expect(graph.objects.flatMap((o) => o.fields).some((f) => f.reference), c.id).toBe(true);
142+
}
143+
});
144+
145+
it('refuses an object with no name, and fields that are not a name-keyed record', () => {
146+
expect(() => artifactToEvalGraph({ objects: [{ fields: {} }] })).toThrow(TypeError);
147+
expect(() => artifactToEvalGraph({ objects: [{ name: 'a', fields: [{ name: 'x', type: 'text' }] }] }))
148+
.toThrow(/not a name-keyed record/);
149+
});
150+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// Ported from the cloud eval harness under the cloud#3600 D2 ruling (the graph reader in agent-e2e.ts).
3+
4+
/**
5+
* A built artifact (`dist/objectstack.json`, as `os build` writes it) read into
6+
* the {@link EvalGraph} the scenario scorers judge.
7+
*
8+
* ## One reader of `packages[]`, not a second one
9+
*
10+
* A multi-package artifact may carry its collections only inside
11+
* `packages[i].manifest` (ADR-0130 D4, option B). Every collection here is read
12+
* through `resolveStackCollection` — the rule `@objectstack/cli` and
13+
* `@objectstack/verify` already share (`utils/stack-collections.ts`): the
14+
* top-level array when present, otherwise every package body's, in the
15+
* dependency order `resolveArtifactPackageOrder` decides. ⛔ No local walk of
16+
* `packages[]`.
17+
*
18+
* ## The spec's shapes, and only those
19+
*
20+
* The cloud reader this replaces read a live environment's metadata API and
21+
* tolerated several spellings per key (`navigation ?? nav ?? tabs ?? …`,
22+
* `reference_to ?? referenceTo ?? reference`). An artifact has been parsed by
23+
* the spec before it was written, so each key is read in its ONE declared
24+
* shape: object `fields` is a name-keyed record, a field's target is
25+
* `reference` (through `referenceTargetOf`), an app's nav is `navigation` with
26+
* nested `children`, a widget's measures are `values` and its binding is
27+
* `dataset` (ADR-0021). A scorer that quietly accepted a second spelling would
28+
* score an off-spec build as if it were on spec.
29+
*
30+
* A value that cannot be read in that shape — an object with no name, a
31+
* `fields` that is not a record — is refused with a thrown `TypeError` naming
32+
* where, never read as an empty object: an empty object is a DEGRADED build to
33+
* the scorer, and an unreadable artifact must not score as one.
34+
*/
35+
36+
import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data';
37+
38+
import { resolveStackCollection } from '../utils/stack-collections.js';
39+
import { PLATFORM_OBJECT_PREFIX, type EvalGraph, type EvalGraphField, type EvalGraphNavEntry } from './scenario-score.js';
40+
41+
type Bag = Record<string, unknown>;
42+
43+
const isBag = (value: unknown): value is Bag => !!value && typeof value === 'object' && !Array.isArray(value);
44+
45+
function named(item: unknown, where: string): Bag & { name: string } {
46+
if (!isBag(item) || typeof item.name !== 'string' || item.name === '') {
47+
throw new TypeError(`scenario graph: ${where} has no \`name\` — the artifact cannot be scored.`);
48+
}
49+
return item as Bag & { name: string };
50+
}
51+
52+
/**
53+
* The author-chosen target of a field, or `undefined`.
54+
*
55+
* A type whose target the TYPE fixes (`user` → `sys_user`) has none: whether a
56+
* type fixes its target is asked of the spec (`referenceTargetOf` on the bare
57+
* type answers only for those), never transcribed here.
58+
*/
59+
function authoredReference(def: Bag): string | undefined {
60+
if (typeof def.type !== 'string' || !REFERENCE_VALUE_TYPES.has(def.type)) return undefined;
61+
if (referenceTargetOf({ type: def.type }) !== undefined) return undefined;
62+
return referenceTargetOf(def);
63+
}
64+
65+
function toField(name: string, def: unknown, where: string): EvalGraphField {
66+
if (!isBag(def)) throw new TypeError(`scenario graph: ${where} is not a field definition.`);
67+
const field: EvalGraphField = { name, type: typeof def.type === 'string' ? def.type : '' };
68+
if (def.required === true) field.required = true;
69+
if (Array.isArray(def.options)) {
70+
field.options = def.options.filter(isBag).map((o) => ({
71+
value: String(o.value),
72+
...(typeof o.label === 'string' ? { label: o.label } : {}),
73+
}));
74+
}
75+
const reference = authoredReference(def);
76+
if (reference !== undefined) field.reference = reference;
77+
return field;
78+
}
79+
80+
/** `[kind, targetKey]` per nav item type that targets a named build member. */
81+
const NAV_TARGET_KEYS: Readonly<Record<string, readonly [kind: string, key: string]>> = {
82+
object: ['object', 'objectName'],
83+
dashboard: ['dashboard', 'dashboardName'],
84+
page: ['page', 'pageName'],
85+
report: ['report', 'reportName'],
86+
};
87+
88+
function navEntries(items: unknown, members: Readonly<Record<string, Set<string>>>, out: EvalGraphNavEntry[]): void {
89+
if (!Array.isArray(items)) return;
90+
for (const item of items) {
91+
if (!isBag(item)) continue;
92+
const spec = typeof item.type === 'string' ? NAV_TARGET_KEYS[item.type] : undefined;
93+
if (spec) {
94+
const [kind, key] = spec;
95+
const target = item[key];
96+
if (typeof target === 'string') {
97+
const resolves = members[kind]!.has(target) || (kind === 'object' && PLATFORM_OBJECT_PREFIX.test(target));
98+
out.push({ target, resolves });
99+
}
100+
}
101+
navEntries(item.children, members, out);
102+
}
103+
}
104+
105+
/**
106+
* Read a built artifact into an {@link EvalGraph}.
107+
*
108+
* @param artifact - The artifact exactly as `JSON.parse` returns
109+
* `dist/objectstack.json` — single-package, or multi-package with its
110+
* collections under `packages[]`.
111+
* @throws TypeError when a collection member cannot be read in its spec shape;
112+
* the ADR-0112 refusal of `resolveArtifactPackageOrder` for a malformed
113+
* `packages` array.
114+
*/
115+
export function artifactToEvalGraph(artifact: unknown): EvalGraph {
116+
const collection = (key: string) => resolveStackCollection(artifact, key);
117+
118+
const objects = collection('objects').map((raw, i) => {
119+
const o = named(raw, `objects[${i}]`);
120+
const fieldsRaw = o.fields ?? {};
121+
if (!isBag(fieldsRaw)) {
122+
throw new TypeError(`scenario graph: object ${o.name} has a \`fields\` that is not a name-keyed record.`);
123+
}
124+
return {
125+
name: o.name,
126+
fields: Object.entries(fieldsRaw).map(([name, def]) => toField(name, def, `${o.name}.${name}`)),
127+
};
128+
});
129+
130+
const dashboardsRaw = collection('dashboards').map((raw, i) => named(raw, `dashboards[${i}]`));
131+
const dashboards = dashboardsRaw.map((d) => ({
132+
name: d.name,
133+
widgets: (Array.isArray(d.widgets) ? d.widgets : []).filter(isBag).flatMap((w) => {
134+
const dataset = typeof w.dataset === 'string' ? w.dataset : undefined;
135+
const values = Array.isArray(w.values) ? w.values.filter((v): v is string => typeof v === 'string') : [];
136+
return values.length ? values.map((measure) => ({ dataset, measure })) : [{ dataset }];
137+
}),
138+
}));
139+
140+
const datasets = collection('datasets').map((raw, i) => {
141+
const d = named(raw, `datasets[${i}]`);
142+
return {
143+
name: d.name,
144+
measures: (Array.isArray(d.measures) ? d.measures : [])
145+
.filter(isBag)
146+
.map((m) => m.name)
147+
.filter((n): n is string => typeof n === 'string'),
148+
};
149+
});
150+
151+
const nameSet = (key: string) =>
152+
new Set(collection(key).map((raw) => (isBag(raw) && typeof raw.name === 'string' ? raw.name : '')).filter(Boolean));
153+
const members: Record<string, Set<string>> = {
154+
object: new Set(objects.map((o) => o.name)),
155+
dashboard: new Set(dashboards.map((d) => d.name)),
156+
page: nameSet('pages'),
157+
report: nameSet('reports'),
158+
};
159+
160+
const apps = collection('apps').map((raw, i) => {
161+
const a = named(raw, `apps[${i}]`);
162+
const nav: EvalGraphNavEntry[] = [];
163+
navEntries(a.navigation, members, nav);
164+
return { name: a.name, nav };
165+
});
166+
167+
return { objects, dashboards, apps, datasets };
168+
}

0 commit comments

Comments
 (0)