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
35 changes: 35 additions & 0 deletions .changeset/govern-remaining-nine-metadata-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/spec': minor
'@objectstack/cli': patch
---

Liveness coverage is complete: the nine remaining registered metadata types are
governed (#4488) — `app`, `book`, `doc`, `email_template`, `job`, `mapping`,
`seed`, `translation`, `validation` — and `PENDING_GOVERNANCE` is empty. Every
type in the metadata-type registry now has a ledger with per-property verdicts,
evidence, and a `verifiedAt` stamp.

Spec:

- Nine new ledgers under `packages/spec/liveness/` (≈150 verdicts). Highlights:
the ENTIRE `email_template` authoring surface is dead (nothing materializes
metadata items into the `sys_email_template` rows `sendTemplate` reads — an
admin editing the password-reset mail in Studio changes nothing; #4509);
`app.areas[].visible` / `areas[].requiredPermissions` are fail-open dead
gates (item-level siblings ARE enforced); `translation.validationMessages`
is read by nothing while #3778's own migration table steers authors into it;
`job`/`validation` have runtime-authoring doors disconnected from their
execution points (#4509). `doc` and `seed` are fully live.
- `check-liveness.mts`: the walker now sees through `z.preprocess` pipes
(takes the OUT side when the IN side is a transform) — `translation`'s
registered schema was unwalkable before this.
- `liveness/README.md`: the per-type count table's method is now decided and
recorded (it mirrors `check-liveness.mts --json` `byStatus`, the number CI
enforces); all rows regenerated from one run, and the two-generations-stale
`webhook` row rewritten to the post-#3489/#3494 state.

CLI:

- `lint-liveness-properties` registers the six newly governed types that carry
`authorWarn` entries (`apps`, `books`, `jobs`, `emailTemplates`, `mappings`,
`translations`), so authors hear about the misleading keys at compile time.
132 changes: 132 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,136 @@ describe('lintLivenessProperties', () => {
});
expect(findings).toEqual([]);
});

// ── #4488 — the nine remaining types, governed. Pins run against the REAL
// ledgers, one per finding class the audit surfaced.

// The app ledger's most important entries: area-level gating keys that FAIL
// OPEN (nothing evaluates them, so a "hidden"/"gated" area shows for
// everyone), on the surface whose item-level siblings ARE enforced.
it('warns on the fail-open area gates and dead homePageId (#4488)', () => {
const findings = lintLivenessProperties({
apps: [{
name: 'crm',
label: 'CRM',
homePageId: 'nav_pipeline',
areas: [{
id: 'area_sales',
label: 'Sales',
order: 2,
visible: "'sales' in current_user.positions",
requiredPermissions: ['crm.access'],
navigation: [],
}],
}],
});
const msgs = paths(findings);
expect(msgs.some((m) => m.includes('homePageId'))).toBe(true);
expect(msgs.some((m) => m.includes('areas.order'))).toBe(true);
expect(msgs.some((m) => m.includes('areas.visible'))).toBe(true);
expect(msgs.some((m) => m.includes('areas.requiredPermissions'))).toBe(true);
// The gating hints must point at the enforced alternative (per-item gates),
// or the warning just relocates the author's confusion.
const perms = findings.find((f) => f.message.includes('areas.requiredPermissions'));
expect(perms!.hint).toMatch(/per item|Per-item/i);
});

// email_template: the WHOLE authoring surface is disconnected from
// sendTemplate (webhook shape) — one per-artifact warn carried on `name`.
it('warns once per email_template artifact via name (#4488)', () => {
const findings = lintLivenessProperties({
emailTemplates: [{
name: 'crm.welcome',
label: 'Welcome',
subject: 'Hi {{user.name}}',
bodyHtml: '<p>Welcome</p>',
}],
});
const hit = findings.find((f) => f.message.includes('`name`'));
expect(hit).toBeDefined();
expect(hit!.hint).toMatch(/sys_email_template/);
});

// translation.validationMessages: pointed at by #3778's own migration table,
// read by nothing — the hint must say what actually renders (rule.message).
it('warns on translation.validationMessages (#4488)', () => {
const findings = lintLivenessProperties({
translations: [{
name: 'zh_cn',
locale: 'zh-CN',
validationMessages: { discount_limit: '折扣不能超过40%' },
}],
});
const hit = findings.find((f) => f.message.includes('validationMessages'));
expect(hit).toBeDefined();
expect(hit!.hint).toMatch(/message/);
});

// book: both inline translations maps are dead (the doc-level map two files
// over works, which is what makes these read alive); job.id and
// mapping.extractQuery are the other flat dead keys.
it('warns on book/job/mapping dead keys (#4488)', () => {
const findings = lintLivenessProperties({
books: [{
name: 'crm_guide',
label: 'CRM Guide',
translations: { 'zh-CN': { label: 'CRM 指南' } },
groups: [{ key: 'basics', label: 'Basics', translations: { 'zh-CN': { label: '基础' } } }],
}],
jobs: [{
name: 'nightly_sync',
id: 'job_nightly',
schedule: { type: 'cron', expression: '0 0 * * *' },
handler: 'syncAll',
}],
mappings: [{
name: 'csv_import_contacts',
targetObject: 'contact',
fieldMapping: [],
extractQuery: { object: 'contact', fields: ['name'] },
}],
});
const msgs = paths(findings);
expect(msgs.some((m) => m.includes('`translations`'))).toBe(true);
expect(msgs.some((m) => m.includes('groups.translations'))).toBe(true);
expect(msgs.some((m) => m.includes('`id`'))).toBe(true);
expect(msgs.some((m) => m.includes('extractQuery'))).toBe(true);
});

// The unwarnable-default rule, negative direction: errorPolicy/batchSize
// (mapping) and includeAll/placement (app selectors) materialize from schema
// defaults on every compiled artifact, so their dead entries carry
// _authorWarnSkipped instead of authorWarn — a compiled stack that only has
// defaults must stay silent.
it('stays silent on schema-default values and live-only artifacts (#4488)', () => {
const findings = lintLivenessProperties({
mappings: [{
name: 'api_sync_orders',
targetObject: 'order',
fieldMapping: [{ source: 'Total', target: 'total' }],
mode: 'upsert',
upsertKey: ['external_ref'],
// materialized defaults — must NOT warn:
sourceFormat: 'csv',
errorPolicy: 'skip',
batchSize: 1000,
}],
apps: [{
name: 'sales',
label: 'Sales',
contextSelectors: [{
id: 'active_region',
label: 'Region',
optionsSource: { endpoint: '/api/v1/regions', valueKey: 'id', labelKey: 'name' },
// materialized defaults — must NOT warn:
includeAll: true,
allValue: '',
persist: 'query',
placement: 'sidebar_header',
}],
}],
seeds: [],
});
expect(findings).toEqual([]);
});
});
9 changes: 9 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
// collection registered or its ledger warns nobody, which would leave the
// ledger correct and silent: the exact shape this lint exists to prevent.
{ type: 'datasource', key: 'datasources' },
// #4488 — the six newly governed types that carry `authorWarn` entries.
// (doc / seed / validation are governed too but warn on nothing today, so
// they are not listed; add them here the day one of their entries warns.)
{ type: 'app', key: 'apps' },
{ type: 'book', key: 'books' },
{ type: 'job', key: 'jobs' },
{ type: 'email_template', key: 'emailTemplates' },
{ type: 'mapping', key: 'mappings' },
{ type: 'translation', key: 'translations' },
];

/**
Expand Down
Loading
Loading