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
7 changes: 7 additions & 0 deletions .changeset/adr-0048-namespace-prefix-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@objectstack/cli": minor
---

ADR-0048 follow-up: `os lint` now emits a `naming/namespace-prefix` **warning** when a bare-named UI/automation item is not namespace-prefixed. This shifts the cross-package collision detection (ADR-0048, runtime `MetadataCollisionError`) left to authoring time — a soft nudge to prefix `app`/`page`/`dashboard`/`flow`/`action`/`report`/`dataset` names with the package namespace, so a clash with another package is unlikely to ever reach install.

Warning-only and never fatal (only errors fail the lint). An app named after the namespace (ADR-0019 single-app convention, e.g. `crm`) and `sys_`-reserved names are exempt; objects (already prefix-enforced as an error) and object-derived views are untouched.
10 changes: 7 additions & 3 deletions docs/adr/0048-cross-package-metadata-collision.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,13 @@ detect.**
an error; the fix (rename with a namespace prefix, or `warn` during
migration) is in the message.
- No change to the key shape, the overlay model, or object/nav paths.
- Follow-ups (not in this ADR): a CLI lint that flags non-prefixed
bare-named metadata as a warning; prefix-strict spec validation for the
next net-new bare-named type.
- Follow-ups: a CLI lint that flags non-prefixed bare-named metadata as a
warning — **implemented** as the `naming/namespace-prefix` rule in
`os lint` (covers `app`/`page`/`dashboard`/`flow`/`action`/`report`/
`dataset`; exempts an app named after the namespace per ADR-0019 and
`sys_` names; warning-only, never fatal). This shifts detection left from
the registration-time guard to authoring time. Still open: prefix-strict
spec validation for the next net-new bare-named type.

## 5. Implementation notes

Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,51 @@ export function lintConfig(config: any): LintIssue[] {
}
}

// ── Namespace-prefix advisory for bare-named metadata (ADR-0048 §3.3) ──
// Cross-package collision detection (ADR-0048) makes a clash between two
// packages' bare-named items (`page`/`flow`/`action`/…) a loud error at
// registration time. This shifts that left: a non-fatal nudge to prefix
// the name with the package namespace at authoring time, so the clash is
// unlikely to ever happen. Objects are already prefix-*enforced* (error)
// in defineStack; views are object-derived; `doc` has its own build lint
// — so they are excluded here. Warning only — prefix stays a recommended
// convention for legacy types, not a retroactive requirement.
const ns: string | undefined = config.manifest?.namespace;
if (ns) {
// A name is namespace-safe if it IS the namespace (ADR-0019: a package's
// single app is conventionally named after the namespace, e.g. `crm`),
// is prefixed with it (`crm_lead`), or is a platform-reserved `sys_` name.
const isNamespaceSafe = (name: string) =>
name === ns || name.startsWith(`${ns}_`) || name.startsWith('sys_');

// Bare-named UI/automation types subject to the cross-package collision
// (ADR-0048 §1.1). Data-driven so a new bare-named type is one line.
const PREFIXED_TYPES: Array<{ key: string; label: string }> = [
{ key: 'apps', label: 'App' },
{ key: 'pages', label: 'Page' },
{ key: 'dashboards', label: 'Dashboard' },
{ key: 'flows', label: 'Flow' },
{ key: 'actions', label: 'Action' },
{ key: 'reports', label: 'Report' },
{ key: 'datasets', label: 'Dataset' },
];

for (const { key, label } of PREFIXED_TYPES) {
const items: any[] = Array.isArray(config[key]) ? config[key] : [];
for (let i = 0; i < items.length; i++) {
const name = items[i]?.name;
if (typeof name !== 'string' || !name || isNamespaceSafe(name)) continue;
issues.push({
severity: 'warning',
rule: 'naming/namespace-prefix',
message: `${label} "${name}" is not namespace-prefixed. Two packages defining "${name}" collide on the registry key and fail at install (ADR-0048). Rename to "${ns}_${name}".`,
path: `${key}[${i}].name`,
fix: `${ns}_${name}`,
});
}
}
}

// ── Data-model best practices (relationships / master-detail / roll-ups) ──
// Cross-object rules that encode the conventions in ADR-0035 and the
// objectstack-data/-ui skills. These double as the eval rubric (see score.ts).
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/test/lint-namespace-prefix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { lintConfig } from '../src/commands/lint';

const RULE = 'naming/namespace-prefix';
const prefixIssues = (config: any) => lintConfig(config).filter((i) => i.rule === RULE);

describe('lint — namespace-prefix advisory (ADR-0048 §3.3)', () => {
it('warns on a bare-named page without the namespace prefix', () => {
const issues = prefixIssues({
manifest: { namespace: 'crm' },
pages: [{ name: 'home', label: 'Home' }],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].path).toBe('pages[0].name');
expect(issues[0].fix).toBe('crm_home');
expect(issues[0].message).toContain('ADR-0048');
});

it('accepts a namespace-prefixed name', () => {
expect(
prefixIssues({ manifest: { namespace: 'crm' }, flows: [{ name: 'crm_onboarding' }] }),
).toHaveLength(0);
});

it('exempts an app named after the namespace (ADR-0019 single-app convention)', () => {
// `defineApp({ name: 'crm' })` in namespace `crm` must NOT warn.
expect(
prefixIssues({ manifest: { namespace: 'crm' }, apps: [{ name: 'crm' }] }),
).toHaveLength(0);
});

it('exempts platform-reserved sys_ names', () => {
expect(
prefixIssues({ manifest: { namespace: 'crm' }, pages: [{ name: 'sys_admin' }] }),
).toHaveLength(0);
});

it('covers every bare-named UI/automation type', () => {
const issues = prefixIssues({
manifest: { namespace: 'crm' },
apps: [{ name: 'other' }],
pages: [{ name: 'home' }],
dashboards: [{ name: 'overview' }],
flows: [{ name: 'onboard' }],
actions: [{ name: 'send' }],
reports: [{ name: 'pipeline' }],
datasets: [{ name: 'sales' }],
});
expect(issues).toHaveLength(7);
expect(new Set(issues.map((i) => i.severity))).toEqual(new Set(['warning']));
});

it('is silent when the package declares no namespace', () => {
// No manifest.namespace → nothing to prefix against; stays quiet.
expect(prefixIssues({ pages: [{ name: 'home' }] })).toHaveLength(0);
});

it('does not touch objects (already prefix-enforced as an error) or views', () => {
const issues = prefixIssues({
manifest: { namespace: 'crm' },
objects: [{ name: 'lead', fields: { id: { type: 'text' } } }],
views: [{ name: 'lead.all' }],
});
expect(issues).toHaveLength(0);
});
});