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
52 changes: 52 additions & 0 deletions .changeset/reference-integrity-object-and-action-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/cli': minor
---

Reference-integrity validation for object and action names (issue #3583)

A HotCRM audit found ~20 shipped instances of one bug class — metadata naming
something that does not exist — all passing `objectstack validate` / `lint`
cleanly and failing silently at runtime. This closes the object-name and
action-name half of that class.

**New — `@objectstack/spec`:** `PLATFORM_PROVIDED_OBJECT_NAMES`, a curated
registry of every object name contributed by a platform package, official
plugin, or the cloud runtime, plus `isPlatformProvidedObjectName()` and
`hasPlatformObjectPrefix()`. This replaces the `startsWith('sys_')` prefix guess
that could not tell `sys_user` (real) from `sys_approval_process` (fictional —
removed by ADR-0019, registered by nothing), which is why every fictional
platform-prefixed reference shipped. A conformance test scans each package's
`*.object.ts` declarations and fails if the registry drifts.

**New lint rules** (wired into both `os validate` and `os lint`):

- `validate-object-references` — action-param `reference` / `objectOverride`,
dashboard `globalFilters[].optionsFrom.object`, and navigation
`requiresObject` gates. Severity follows resolvability: an unresolved
*unprefixed* name is a typo (**error** — `object: 'user'` where the platform
object is `sys_user`); an unresolved *platform-prefixed* name is **advisory**,
since a third-party package may still provide it.
- `validate-action-name-refs` — the surfaces that bind an action BY NAME:
list-view `bulkActions` / `rowActions`, page `record:quick_actions`
`actionNames`, and nav action items. A name matching no defined action is an
**error** (the button renders and does nothing), matching the existing
dashboard-action-target rule.

**Fixes:**

- `defineStack` cross-reference validation now walks `app.areas[].navigation` —
an areas-based app previously got no navigation checking at all — and recurses
into `children` on `object` nav items, not only `group` ones.
- `os lint` i18n coverage now reads field `options` in the canonical
`{value,label}[]` array shape; it only handled the record map, so option-label
coverage silently never fired for canonically-shaped select fields.
- Hook `condition` expressions are now field-checked when `object` is an ARRAY
of targets (previously only a single string target was checked, so a
multi-target hook filtering on a nonexistent field passed clean). Per-target
diagnostics are de-duplicated.
- A dashboard widget binding no `dataset` at all is now reported instead of
silently bypassing every binding and chart check on the raw-config
(`lint`/`doctor`) paths. `dataset` is schema-required, so this matches what
the parsed paths already enforce.
34 changes: 34 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ header: {
whose `objects/reports/dashboards/pages/views` route is unregistered is **warned**
(external, interpolated, and opaque routes are skipped).

### 4. Dangling object and action names

The same gate covers the reference sites that are plain strings in the schema:
an action param's record-picker target, a dashboard filter's options source, a
navigation capability gate, and every surface that binds an action **by name**
(`bulkActions` / `rowActions`, a page's `record:quick_actions`, a nav action item).

```ts
// ✗ the platform user object is `sys_user` — `user` resolves to nothing → error
{ name: 'owner', type: 'lookup', reference: 'user' }

// ✗ no action named `mass_update` is defined anywhere → error
defineView({ /* … */ bulkActions: ['mass_update', 'mass_delete'] })

// ⚠ platform-shaped, but no known package registers it → warning
{ id: 'nav_approvals', type: 'object', objectName: 'sys_approval_process',
requiresObject: 'sys_approval_process' }
```

Severity follows **resolvability**, because "this might be provided by another
installed package" is a real possibility that must not be guessed at:

| The name… | Result |
|---|---|
| resolves to one of your own objects | ✓ |
| is unresolved and carries no platform prefix | **error** — your objects are namespace-prefixed and present in the stack, so there is no legitimate elsewhere. This is the typo class (`user` for `sys_user`). |
| resolves to a known platform / plugin / cloud object | ✓ |
| carries a platform prefix but no known package registers it | **warning** — a third-party package may still provide it, so this stays advisory |

Interpolated targets (`${…}`, `{…}`) are skipped — they resolve at render time.
Action names get no third-party softening: the runtime ships no built-in action
names, so a name resolving nowhere is always an **error**.

## The one gate, two entry points

`os validate` and `os build` (alias of `os compile`) run the **same** validator:
Expand All @@ -93,6 +126,7 @@ whose `objects/reports/dashboards/pages/views` route is unregistered is **warned
| CEL / predicate validation | ✓ | ✓ |
| Widget-binding integrity | ✓ | ✓ |
| Dashboard action/route references (ADR-0049) | ✓ | ✓ |
| Object & action name references (#3583) | ✓ | ✓ |
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ |
| Emits `dist/objectstack.json` | — | ✓ |

Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
Expand Down Expand Up @@ -319,6 +320,43 @@ export default class Compile extends Command {
this.exit(1);
}

// 3b-bis. Object & action name references (#3583) — the reference sites
// `defineStack` does not cover: action-param `reference` /
// `objectOverride`, dashboard filter `optionsFrom.object`, nav
// `requiresObject` gates, and the name-bound action surfaces
// (`bulkActions`/`rowActions`, page quick-actions, nav action items).
// All plain strings in the schema, so a name resolving to nothing
// ships and fails silently. Errors fail the build; the
// platform-prefixed-but-unregistered case is advisory (a third-party
// package may still provide it).
if (!flags.json) printStep('Checking object & action references (#3583)...');
const refFindings = [
...validateObjectReferences(result.data as Record<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
if (refErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({ success: false, error: 'reference integrity validation failed', issues: refErrors }));
this.exit(1);
}
console.log('');
printError(`Reference integrity check failed (${refErrors.length} issue${refErrors.length > 1 ? 's' : ''})`);
for (const f of refErrors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
this.exit(1);
}
if (!flags.json) {
for (const w of refWarnings.slice(0, 50)) {
console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`));
console.log(chalk.dim(` ${w.hint}`));
}
}

// 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without
// an `id` drops its CSS silently; Tailwind-in-className does nothing
// from metadata. Same bar for hand-authored and AI-generated pages
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.
import { lintDataModel } from '../lint/data-model-rules.js';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
Expand Down Expand Up @@ -487,6 +488,38 @@ export function lintConfig(config: any): LintIssue[] {
});
}

// ── Object-name references (issue #3583) ──
// The reference sites `defineStack` does not cover: action-param
// `reference`/`objectOverride`, dashboard filter `optionsFrom.object`, and
// navigation `requiresObject` gates. An unprefixed miss (`user` for
// `sys_user`) is a typo → error; a platform-prefixed name no known package
// registers (`sys_approval_process`) is advisory, since a third-party
// package may still provide it.
for (const t of validateObjectReferences(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Action-name references (issue #3583) ──
// `bulkActions`/`rowActions`, page `quick_actions`, and nav action items bind
// an action BY NAME. A name matching no defined action ships a button that
// renders and does nothing — same failure `validate-dashboard-action-refs`
// catches for dashboards, so the same severity.
for (const t of validateActionNameRefs(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

return issues;
}

Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { validateViewContainers } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import { validateCapabilityReferences } from '@objectstack/lint';
Expand Down Expand Up @@ -281,6 +282,48 @@ export default class Validate extends Command {
this.exit(1);
}

// 3a-quater. Object-name + action-name reference integrity (#3583). The
// reference sites `defineStack` does not cover: action-param
// `reference`/`objectOverride`, dashboard filter `optionsFrom.object`,
// nav `requiresObject` gates, and the name-bound action surfaces
// (`bulkActions`/`rowActions`, page quick-actions, nav action items).
// All are plain strings in the schema, so a name resolving to nothing
// parses, ships, and fails silently at runtime. An unprefixed miss is
// a typo (error); a platform-prefixed name no known package registers
// is advisory (a third-party package may still provide it).
if (!flags.json) printStep('Checking object & action references (#3583)...');
const refFindings = [
...validateObjectReferences(result.data as Record<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
if (refErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({
valid: false,
errors: refErrors,
warnings: [...widgetWarnings, ...actionRefWarnings, ...refWarnings],
duration: timer.elapsed(),
}, null, 2));
this.exit(1);
}
console.log('');
printError(`Reference integrity check failed (${refErrors.length} issue${refErrors.length > 1 ? 's' : ''})`);
for (const f of refErrors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
this.exit(1);
}
if (!flags.json) {
for (const w of refWarnings.slice(0, 50)) {
console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`));
console.log(chalk.dim(` ${w.hint}`));
}
}

// 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's
// responsiveStyles must be scopable (needs an `id`), reference real
// CSS properties + design tokens, and carry a `large` base;
Expand Down
37 changes: 25 additions & 12 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,19 +209,32 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
`Field ${objectName}.${fieldName} label`,
inlineText(field?.label),
);
// Options — accept BOTH shapes, exactly as `i18n-extract.ts` does.
// `FieldSchema.options` is canonically a `{value, label}[]` ARRAY, but
// this only ever handled the record map, so option coverage silently
// never fired for a canonically-shaped select field (issue #3583).
const opts = field?.options;
if (opts && typeof opts === 'object' && !Array.isArray(opts)) {
for (const [optionKey, optionLabel] of Object.entries<any>(opts)) {
// Mirrors the extractor: an option's source text is its label, or
// its own value when the map holds no label string.
pushKey(
keys,
['objects', objectName, 'fields', fieldName, 'options', optionKey],
'option',
`Option ${objectName}.${fieldName}.${optionKey}`,
inlineText(optionLabel) ?? optionKey,
);
}
const optionEntries: Array<[string, unknown]> = Array.isArray(opts)
? opts.flatMap((opt: any) =>
opt && typeof opt === 'object' && 'value' in opt
? [[String(opt.value), opt.label ?? opt.value] as [string, unknown]]
: typeof opt === 'string'
? [[opt, opt] as [string, unknown]]
: [],
)
: opts && typeof opts === 'object'
? Object.entries<any>(opts)
: [];
for (const [optionKey, optionLabel] of optionEntries) {
// Mirrors the extractor: an option's source text is its label, or
// its own value when no label string is present.
pushKey(
keys,
['objects', objectName, 'fields', fieldName, 'options', optionKey],
'option',
`Option ${objectName}.${fieldName}.${optionKey}`,
inlineText(optionLabel) ?? optionKey,
);
}
}
}
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/test/i18n-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,51 @@ describe('computeI18nCoverage', () => {
expect(zhKeys.has('globalActions.export_csv.successMessage')).toBe(true);
});

// Issue #3583 — `FieldSchema.options` is canonically a `{value,label}[]`
// ARRAY, but coverage only walked the record-map shape, so option-label
// coverage silently never fired for a canonically-shaped select field.
it('covers options declared in the canonical array shape', () => {
const arrayOptionConfig: any = {
objects: [
{
name: 'account',
label: 'Account',
fields: {
stage: {
label: 'Stage',
options: [
{ value: 'planning', label: 'Planning' },
{ value: 'direct_mail', label: 'Direct Mail' },
],
},
},
},
],
translations: [{ 'zh-CN': {} }],
};
const report = computeI18nCoverage(arrayOptionConfig, { defaultLocale: 'en' });
const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key));
expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(true);
expect(zhKeys.has('objects.account.fields.stage.options.direct_mail')).toBe(true);
});

it('covers options declared as a bare string array', () => {
const stringOptionConfig: any = {
objects: [
{
name: 'account',
label: 'Account',
fields: { stage: { label: 'Stage', options: ['planning', 'closed'] } },
},
],
translations: [{ 'zh-CN': {} }],
};
const report = computeI18nCoverage(stringOptionConfig, { defaultLocale: 'en' });
const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key));
expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(true);
expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(true);
});

it('promotes warnings to errors under --strict', () => {
const report = computeI18nCoverage(baseConfig, { defaultLocale: 'en', strict: true });
const zhIssues = report.issues.filter((i) => i.locale === 'zh-CN');
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,14 @@ export type {
export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';

export {
validateObjectReferences,
OBJECT_REFERENCE_UNKNOWN,
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
} from './validate-object-references.js';
export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-references.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
Loading
Loading