Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/1582-retire-tests-strict-lint-owns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Tests only, releases nothing. For maintainers: the local test assertions whose every case `objectstack lint --strict` (`pnpm lint`) now reports are retired, row by row — the inert decision `config.condition`, `list.tabs`, page and form field references, field-group integrity, skill tool names, report dataset/dimension/measure bindings, preset filter comparands, the option-key, navigation-orphan and section-name translation checks, FLS key qualification and field names, and RLS predicates (#1582, #1583, #1584, #1585, #1586, #1805). `pnpm verify` still runs `lint:i18n-gate`: on the two-package artifact, `objectstack lint`'s `i18n/missing-*` coverage reports nothing, so the gate was not retired.
2 changes: 1 addition & 1 deletion content/docs/analytics/cubes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ So the three questions this section offered — *"ROI by campaign type"*, *"cost

## How users interact with datasets

What the app guarantees is the binding: a report or a dashboard tile names a dataset and selects its measures and dimensions by name, and `test/analytics-integrity.test.ts` fails the build if any of those names does not resolve. That is what keeps two tiles showing the same number — and it is also why the names in `src/*/datasets/` are worth reading directly rather than through a copy.
What the app guarantees is the binding: a report or a dashboard tile names a dataset and selects its measures and dimensions by name, and the platform's own checks — `objectstack validate` and `objectstack lint --strict`, which `pnpm verify` and CI both run — fail the build if any of those names does not resolve. That is what keeps two tiles showing the same number — and it is also why the names in `src/*/datasets/` are worth reading directly rather than through a copy.

The drag-and-drop pivot experience this section used to describe — pick a cube, drag dimensions to rows and columns, filter, pivot, chart, save the view as a report, pin it to a dashboard — is a **platform** surface rather than something HotCRM declares. Nothing in this app's metadata configures such a screen, and nothing here tests one, so this page makes no claim about it in either direction. If your workflow depends on self-service pivoting, verify it against your deployment rather than against this page.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/analytics/cubes.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ description: HotCRM 分析底下的语义层——它是什么、回答哪些业

## 用户如何与 dataset 打交道

应用能保证的是这层绑定:报表或仪表盘磁贴按名字点出一个 dataset,并按名字选它的度量与维度,而 `test/analytics-integrity.test.ts` 会在其中任何一个名字解析不到时让构建失败。正是这一点让两个磁贴显示同一个数字 —— 这同时也是为什么 `src/*/datasets/` 里的那些名字值得直接去读,而不是隔着一份副本读。
应用能保证的是这层绑定:报表或仪表盘磁贴按名字点出一个 dataset,并按名字选它的度量与维度,而平台自身的检查(`pnpm verify` 与 CI 都会运行的 `objectstack validate` 和 `objectstack lint --strict`)会在其中任何一个名字解析不到时让构建失败。正是这一点让两个磁贴显示同一个数字 —— 这同时也是为什么 `src/*/datasets/` 里的那些名字值得直接去读,而不是隔着一份副本读。

本节曾经描述的那套拖放透视体验 —— 选一个 cube、把维度拖到行和列、筛选、透视、出图、把视图存成报表、钉到仪表盘 —— 是**平台**侧的界面,而不是 HotCRM 声明出来的东西。本应用的元数据里没有任何东西配置这样一块屏,本仓也没有任何测试覆盖它,所以本页对它不做任何方向上的断言。如果你的工作流依赖自助透视,请对着你自己的部署去验证,而不是对着本页。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/analytics/cubes.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ description: HotCRM 分析底下的語義層——它是什麼、回答哪些業

## 使用者如何與 dataset 打交道

應用能保證的是這層繫結:報表或儀表板磁貼按名字點出一個 dataset,並按名字選它的度量與維度,而 `test/analytics-integrity.test.ts` 會在其中任何一個名字解析不到時讓建構失敗。正是這一點讓兩個磁貼顯示同一個數字 —— 這同時也是為什麼 `src/*/datasets/` 裡的那些名字值得直接去讀,而不是隔著一份副本讀。
應用能保證的是這層繫結:報表或儀表板磁貼按名字點出一個 dataset,並按名字選它的度量與維度,而平台自身的檢查(`pnpm verify` 與 CI 都會執行的 `objectstack validate` 和 `objectstack lint --strict`)會在其中任何一個名字解析不到時讓建構失敗。正是這一點讓兩個磁貼顯示同一個數字 —— 這同時也是為什麼 `src/*/datasets/` 裡的那些名字值得直接去讀,而不是隔著一份副本讀。

本節曾經描述的那套拖放樞紐體驗 —— 選一個 cube、把維度拖到列和欄、篩選、樞紐、出圖、把檢視存成報表、釘到儀表板 —— 是**平台**側的介面,而不是 HotCRM 宣告出來的東西。本應用的中繼資料裡沒有任何東西組態這樣一塊螢幕,本倉也沒有任何測試涵蓋它,所以本頁對它不做任何方向上的斷言。如果你的工作流依賴自助樞紐分析,請對著你自己的部署去驗證,而不是對著本頁。

Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ the platform assistant by surface affinity.
Skills declare no bespoke tools (ADR-0109). They compose the platform's data
tools with the `action_<name>` tools the runtime materialises from Actions that
opt in via `ai.exposed` (ADR-0011) — every name a skill declares must resolve to
one of those, which `test/skills-integrity.test.ts` enforces.
one of those, which `pnpm lint` (`objectstack lint --strict`, rule
`ai-skill-tool-unresolved`) enforces.

The `live_data` skill explicitly requires live schema inspection before
answering record questions, because admins can change metadata over time.
Expand Down
9 changes: 5 additions & 4 deletions docs/developers/code_examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,7 @@ sources resolve, and only two:
guess at. The ones a CRM skill wants are `describe_object`, `list_objects`,
`query_records`, `query_data`, `get_record`, `aggregate_data`,
`search_knowledge` and `visualize_data`. From 17.0 the authoritative set is
exported as `PLATFORM_PROVIDED_TOOL_NAMES` from `@objectstack/spec/system`;
before 17.0 it is transcribed into `test/skills-integrity.test.ts`. Note
exported as `PLATFORM_PROVIDED_TOOL_NAMES` from `@objectstack/spec/system`. Note
`search_knowledge` retrieves over a *declared knowledge source*, and there is
currently nowhere in a skills-only app to declare one.
- **`action_<name>`** — materialised from an Action that opts in with
Expand All @@ -309,8 +308,10 @@ sources resolve, and only two:
Authoring a `defineTool` record does **not** create a third source: `ToolSchema`
is a read-only projection for Studio discovery with no `implementation` field
and no executor. Reasoning — scoring, drafting, forecasting — belongs in
`instructions`, not in a tool (ADR-0109). `test/skills-integrity.test.ts`
enforces all of this at PR time.
`instructions`, not in a tool (ADR-0109). At PR time `pnpm lint` resolves every
tool name (`ai-skill-tool-unresolved`), and `test/skills-integrity.test.ts`
refuses `stack.tools` records and checks that a referenced Action is AI-exposed
with a headless path.

Note there is no `permissions` key on a skill — `SkillSchema` has no such field,
so one is silently stripped ([#511](https://github.com/objectstack-ai/hotcrm/pull/511)).
Expand Down
5 changes: 3 additions & 2 deletions src/sales/profiles/marketing-user.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ export const MarketingUserProfile = {
name: 'opportunity_private_owner_only_marketing',
// A deal flagged Private is visible only to its owner, even to holders of
// org-wide opportunity read. (`rowLevelSecurity[].label` / `description`
// have no runtime effect — liveness: dead in 17.6.0 — so the intent lives
// in this comment.)
// are display copy — the 17.7.0 liveness ledger marks both live: the
// permission editor draws them on the policy card. This policy authors
// neither, so its intent lives in this comment.)
object: 'crm_opportunity',
operation: 'select' as const,
using: 'is_private == false || owner_id == current_user.id',
Expand Down
5 changes: 3 additions & 2 deletions src/sales/profiles/sales-manager.profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ export const SalesManagerProfile = {
name: 'opportunity_private_owner_only',
// A deal flagged Private is visible only to its owner, even to holders of
// org-wide opportunity read. (`rowLevelSecurity[].label` / `description`
// have no runtime effect — liveness: dead in 17.6.0 — so the intent lives
// in this comment.)
// are display copy — the 17.7.0 liveness ledger marks both live: the
// permission editor draws them on the policy card. This policy authors
// neither, so its intent lives in this comment.)
object: 'crm_opportunity',
operation: 'select' as const,
using: 'is_private == false || owner_id == current_user.id',
Expand Down
87 changes: 16 additions & 71 deletions test/analytics-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { DATE_RANGE_PRESETS } from '@objectstack/spec/ui';
import stack from './helpers/composed-stack';

/**
* Analytics metadata guards (#492).
*
* `os validate` / `build` check that a report HAS a chart and a dataset name,
* but never that the chart's axes resolve to measures/dimensions the dataset
* actually defines, nor that filter time-windows stay relative at runtime.
* Each rule below was a real defect:
* `os validate` / `build` check that a report HAS a chart and a dataset name.
* Whether the dataset and its dimensions and measures resolve is checked by
* `objectstack lint --strict` (`pnpm lint`; see the retired block below), which
* catches the defect this file was first written for: case/opportunity report
* charts named measures (`case_number`, `amount`) that exist in no dataset, so
* the yAxis rendered empty. What stays here, each rule a real defect:
*
* - case/opportunity report charts named measures (`case_number`, `amount`)
* that exist in no dataset — the yAxis rendered empty;
* - churn/opportunity reports computed ISO dates at module load, freezing
* "last 30 days" into dist/objectstack.json at whatever day the artifact
* was built;
Expand All @@ -22,16 +21,9 @@ import stack from './helpers/composed-stack';
*/

type AnyRec = Record<string, any>;
const datasets: AnyRec[] = (stack as any).datasets ?? [];
const reports: AnyRec[] = (stack as any).reports ?? [];
const dashboards: AnyRec[] = (stack as any).dashboards ?? [];

const datasetByName = new Map(datasets.map((d) => [d.name, d]));
const measuresOf = (dataset: string): Set<string> =>
new Set((datasetByName.get(dataset)?.measures ?? []).map((m: AnyRec) => m.name));
const dimensionsOf = (dataset: string): Set<string> =>
new Set((datasetByName.get(dataset)?.dimensions ?? []).map((d: AnyRec) => d.name));

/** A report or a joined-report block — anything carrying a dataset binding. */
const reportBlocks = (r: AnyRec): AnyRec[] =>
r.type === 'joined' && Array.isArray(r.blocks)
Expand All @@ -40,41 +32,12 @@ const reportBlocks = (r: AnyRec): AnyRec[] =>
const allBlocks = reports.flatMap(reportBlocks);
const labelOf = (b: AnyRec) => (b.__parent ? `${b.__parent}/${b.name}` : b.name);

describe('report dataset bindings resolve', () => {
it('every report block names a defined dataset', () => {
const bad = allBlocks
.filter((b) => b.dataset && !datasetByName.has(b.dataset))
.map((b) => `${labelOf(b)}: dataset "${b.dataset}" is not defined`);
expect(bad, bad.join('\n ')).toEqual([]);
});

it('every chart xAxis is a dimension of the report dataset', () => {
const bad: string[] = [];
for (const b of allBlocks) {
if (!b.chart?.xAxis || !b.dataset) continue;
if (!dimensionsOf(b.dataset).has(b.chart.xAxis)) {
bad.push(`${labelOf(b)}: xAxis "${b.chart.xAxis}" is not a dimension of "${b.dataset}"`);
}
}
expect(bad, `chart xAxis names a missing dimension:\n ${bad.join('\n ')}`).toEqual([]);
});

it('every rows / columns / values entry resolves against the dataset', () => {
const bad: string[] = [];
for (const b of allBlocks) {
if (!b.dataset || !datasetByName.has(b.dataset)) continue;
const dims = dimensionsOf(b.dataset);
const meas = measuresOf(b.dataset);
for (const d of [...(b.rows ?? []), ...(b.columns ?? [])]) {
if (!dims.has(d)) bad.push(`${labelOf(b)}: dimension "${d}" not in "${b.dataset}"`);
}
for (const v of b.values ?? []) {
if (!meas.has(v)) bad.push(`${labelOf(b)}: measure "${v}" not in "${b.dataset}"`);
}
}
expect(bad, `report bindings name missing dataset members:\n ${bad.join('\n ')}`).toEqual([]);
});
});
// ⚰️ RETIRED (#1584): "every report block names a defined dataset" (A1),
// "every chart xAxis is a dimension of the report dataset" (A3) and "every
// rows / columns / values entry resolves against the dataset" (A4).
// `objectstack lint --strict` reports them as `chart-dataset-unknown`,
// `chart-dimension-unknown` and `chart-measure-unknown` — on a charted, a
// chartless and a joined-report block alike — and `pnpm lint` fails on each.

describe('metric tiles carry no fabricated trend deltas', () => {
/**
Expand Down Expand Up @@ -170,28 +133,10 @@ describe('time windows stay relative at runtime', () => {
expect(bad, `absolute dates frozen into metadata:\n ${bad.join('\n ')}`).toEqual([]);
});

it('no filter comparand is a bare date-range PRESET name (objectstack#8690)', () => {
// A preset name is the picker's vocabulary, not the query layer's. The
// console lowers `last_30_days` into `{ from: '{30_days_ago}', to:
// '{today}' }` before any filter is sent; a preset name written straight
// into metadata never gets that lowering. It is not a `{macro}` either, so
// the resolver does not reject it — it reaches the driver as a literal
// string, compares false against every row, and the query answers HTTP 200
// with ZERO rows and no diagnostic. The symptom is an all-zero dashboard,
// indistinguishable at a glance from the #460 defect that cost this
// dashboard its date picker for a release. Filed upstream as
// objectstack#8690; guarded here because the repair is one grep away.
const presets = new Set<string>(DATE_RANGE_PRESETS as readonly string[]);
const bad: string[] = [];
for (const [where, filter] of filterSources) {
for (const [path, value] of filterLeaves(filter ?? {}, '')) {
if (presets.has(value)) {
bad.push(`${where}${path} = "${value}" — write the macro bounds, e.g. { $gte: '{30_days_ago}' }`);
}
}
}
expect(bad, `preset names used as filter comparands:\n ${bad.join('\n ')}`).toEqual([]);
});
// ⚰️ RETIRED (#1584): "no filter comparand is a bare date-range PRESET name"
// (A8). `objectstack lint --strict` reports a preset name used as a
// comparand — bare, under `$eq` or inside `$in`, on a widget or a report —
// as `filter-preset-comparand` (objectstack#8690), and `pnpm lint` fails on it.
});

describe('no duplicate metric layer', () => {
Expand Down
Loading
Loading