diff --git a/.changeset/adr-0048-namespace-prefix-lint.md b/.changeset/adr-0048-namespace-prefix-lint.md new file mode 100644 index 0000000000..1756691dfd --- /dev/null +++ b/.changeset/adr-0048-namespace-prefix-lint.md @@ -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. diff --git a/docs/adr/0048-cross-package-metadata-collision.md b/docs/adr/0048-cross-package-metadata-collision.md index 98a6104340..a939071cf4 100644 --- a/docs/adr/0048-cross-package-metadata-collision.md +++ b/docs/adr/0048-cross-package-metadata-collision.md @@ -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 diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index c5838cde7e..5b099b09c2 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -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). diff --git a/packages/cli/test/lint-namespace-prefix.test.ts b/packages/cli/test/lint-namespace-prefix.test.ts new file mode 100644 index 0000000000..10ccb7256e --- /dev/null +++ b/packages/cli/test/lint-namespace-prefix.test.ts @@ -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); + }); +});