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
12 changes: 12 additions & 0 deletions .changeset/liveness-author-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/cli": minor
"@objectstack/spec": patch
---

feat(cli): liveness author-warning lint — close the spec-liveness loop on the author side.

The liveness ledgers already classify every authorable property live/experimental/dead with evidence, and the CI gate enforces classification *completeness* — but that knowledge never reached the person (very often an AI) writing the metadata. The new `compile` lint (`lint-liveness-properties.ts`) reads the ledgers and emits an advisory **warning** when an authored object/field sets a property that is misleading at runtime — e.g. `object.enable.feeds` (no feed runtime; comments live on sys_comment), `object.versioning` (no versioning engine), `field.columnName` (driver ignores it; column == field key), `field.maxRating`/`vectorConfig` (renderer reads a different key) — each with a corrective hint toward the supported alternative. Never fails the build (advisory only), consistent with the existing flow anti-pattern lint.

Signal-over-noise by design: warnings are **opt-in per ledger entry** via a new `authorWarn`/`authorHint` annotation (plus `experimental` entries warn by default). Booleans warn only when set truthy, and only `default(false)` flags are marked, so schema defaults (`enable.trash`, `enable.searchable`) never trip it. Coverage grows by annotating more ledger entries, not by changing lint code; today it covers `object` (incl. `enable.*`) and `field`.

- `@objectstack/spec`: ledger entries gain optional `authorWarn`/`authorHint`; `liveness/` is now shipped in the package `files` so the CLI can read it. Seeded annotations on the misleading object capability flags + aspirational blocks and the misleading dead field props. No schema/runtime change.
17 changes: 17 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '../utils/validate-expressions.js';
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
import {
Expand Down Expand Up @@ -208,6 +209,22 @@ export default class Compile extends Command {
}
}

// 3d-bis. Liveness author-warning lint — close the spec-liveness loop on
// the author side: an authored property the ledger marks dead-and-
// misleading (e.g. `object.enable.feeds`, `field.columnName`) or
// experimental is set hopefully but does nothing / isn't enforced at
// runtime. Advisory only; ledger-driven (entries opt in via
// `authorWarn`), so it's high-signal and NEVER fails the build.
const livenessLint = lintLivenessProperties(result.data as Record<string, unknown>);
if (livenessLint.length > 0 && !flags.json) {
console.log('');
for (const fnd of livenessLint) {
printWarning(`${fnd.where}: ${fnd.message}`);
console.log(chalk.dim(` ${fnd.hint}`));
console.log(chalk.dim(` rule: ${fnd.rule}`));
}
}

// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
// `docs: DocSchema[]` and lint the combined set (flatness,
// namespace-prefixed names, MDX/image ban, same-package link
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
lintLivenessProperties,
LIVENESS_DEAD_PROPERTY,
} from './lint-liveness-properties.js';

/**
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
* files the gate enforces), so they double as a contract test: if an
* `authorWarn` annotation is removed from `enable.feeds` / `columnName` / etc.,
* the matching assertion fails.
*/

const objStack = (obj: Record<string, unknown>) => ({ objects: [{ name: 'widget', ...obj }] });
const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
const paths = (findings: { message: string }[]) => findings.map((f) => f.message);

describe('lintLivenessProperties', () => {
it('warns on an authored dead capability flag (enable.feeds: true)', () => {
const findings = lintLivenessProperties(objStack({ enable: { feeds: true } }));
expect(findings.length).toBeGreaterThan(0);
const feeds = findings.find((f) => f.message.includes('enable.feeds'));
expect(feeds).toBeDefined();
expect(feeds!.rule).toBe(LIVENESS_DEAD_PROPERTY);
expect(feeds!.where).toBe("object 'widget'");
expect(feeds!.hint.length).toBeGreaterThan(0);
});

it('does NOT warn on a default-on flag the author left alone (enable.trash: true)', () => {
const findings = lintLivenessProperties(objStack({ enable: { trash: true } }));
expect(paths(findings).some((m) => m.includes('enable.trash'))).toBe(false);
});

it('does NOT warn when a dead boolean flag is explicitly false (enable.files: false)', () => {
const findings = lintLivenessProperties(objStack({ enable: { files: false } }));
expect(paths(findings).some((m) => m.includes('enable.files'))).toBe(false);
});

it('warns on a present dead object block (versioning)', () => {
const findings = lintLivenessProperties(objStack({ versioning: { enabled: true } }));
expect(paths(findings).some((m) => m.includes('versioning'))).toBe(true);
});

it('warns on a misleading dead field prop (columnName)', () => {
const findings = lintLivenessProperties(
objStack({ fields: [{ name: 'code', type: 'text', columnName: 'legacy_code' }] }),
);
const f = findings.find((x) => x.message.includes('columnName'));
expect(f).toBeDefined();
expect(f!.where).toBe("object 'widget' · field 'code'");
});

it('warns on a field-level index flag set true, but not when false', () => {
const on = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: true }] }));
expect(paths(on).some((m) => m.includes('`index`'))).toBe(true);
const off = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: false }] }));
expect(paths(off).some((m) => m.includes('`index`'))).toBe(false);
});

it('is silent for a clean object with only live properties', () => {
const findings = lintLivenessProperties(
objStack({
label: 'Widget',
enable: { apiEnabled: true },
fields: [{ name: 'name', type: 'text', label: 'Name' }],
}),
);
expect(findings).toEqual([]);
});

it('handles objects as a keyed record (not just arrays)', () => {
const findings = lintLivenessProperties({
objects: { widget: { name: 'widget', enable: { feeds: true } } },
});
expect(rules(findings)).toContain(LIVENESS_DEAD_PROPERTY);
});

it('returns [] on an empty / shapeless stack', () => {
expect(lintLivenessProperties({})).toEqual([]);
expect(lintLivenessProperties({ objects: [] })).toEqual([]);
});
});
173 changes: 173 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Build-time lint that closes the spec-liveness loop on the AUTHOR side.
*
* The liveness ledgers (`@objectstack/spec/liveness/<type>.json`) classify every
* authorable metadata property as live / experimental / dead with evidence. The
* CI gate enforces that classification is *complete*, but the ledger's knowledge
* never reached the person (very often an AI) writing the metadata. This lint
* surfaces it: when an authored object/field sets a property the ledger marks as
* dead-and-misleading (or experimental), it emits an advisory WARNING — "you set
* this expecting it to do something; at runtime it does nothing" — with a hint
* toward the supported alternative. It NEVER fails the build.
*
* Signal over noise is the whole point, so the ledger opts in per entry via
* `"authorWarn": true` (+ an optional `"authorHint"`). A property being merely
* `dead` is NOT enough — plenty of dead props are benign display/doc metadata.
* Only entries an author would be *misled* by are marked. Booleans warn only when
* set truthy (so schema defaults like `enable.trash` never trip it); object/
* string/array props warn when present at all.
*/

import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';

export interface LivenessLintFinding {
where: string;
message: string;
hint: string;
rule: string;
}

export const LIVENESS_DEAD_PROPERTY = 'liveness-dead-property';
export const LIVENESS_EXPERIMENTAL_PROPERTY = 'liveness-experimental-property';

type AnyRec = Record<string, unknown>;

interface LedgerEntry {
status?: string;
authorWarn?: boolean;
authorHint?: string;
note?: string;
children?: Record<string, LedgerEntry>;
}

/** Flattened, warn-only view of a type's ledger: propPath → entry (incl. `a.b` children). */
type WarnMap = Map<string, LedgerEntry>;

function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
return [];
}

/** Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or published files). */
function resolveLivenessDir(): string | null {
try {
const require = createRequire(import.meta.url);
const pkgJson = require.resolve('@objectstack/spec/package.json');
const dir = join(dirname(pkgJson), 'liveness');
return existsSync(dir) ? dir : null;
} catch {
return null;
}
}

/** Build the warn-only lookup for one type, flattening one level of `children`. */
function loadWarnMap(dir: string, type: string): WarnMap {
const map: WarnMap = new Map();
const file = join(dir, `${type}.json`);
if (!existsSync(file)) return map;
let ledger: { props?: Record<string, LedgerEntry> };
try {
ledger = JSON.parse(readFileSync(file, 'utf8'));
} catch {
return map;
}
const props = ledger.props || {};
for (const [key, entry] of Object.entries(props)) {
if (entry?.children) {
for (const [ck, centry] of Object.entries(entry.children)) {
if (shouldWarn(centry)) map.set(`${key}.${ck}`, centry);
}
}
if (shouldWarn(entry)) map.set(key, entry);
}
return map;
}

/** An entry warns when explicitly opted in, OR when it's experimental (a declared-but-unenforced guarantee). */
function shouldWarn(entry: LedgerEntry | undefined): boolean {
if (!entry) return false;
return entry.authorWarn === true || entry.status === 'experimental';
}

/** A value that signals authoring intent: booleans only when truthy; everything else when present. */
function isAuthored(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === 'boolean') return value === true;
return true;
}

function describe(entry: LedgerEntry): { kind: string; rule: string } {
if (entry.status === 'experimental') {
return { kind: 'is experimental — declared but NOT enforced at runtime', rule: LIVENESS_EXPERIMENTAL_PROPERTY };
}
return { kind: 'has no runtime effect (liveness: dead)', rule: LIVENESS_DEAD_PROPERTY };
}

/** Check one metadata item's set properties against its type's warn-map. */
function checkItem(
type: string,
item: AnyRec,
whereBase: string,
warnMap: WarnMap,
findings: LivenessLintFinding[],
): void {
for (const [path, entry] of warnMap) {
const value = path.includes('.')
? getNested(item, path)
: item[path];
if (!isAuthored(value)) continue;
const { kind, rule } = describe(entry);
const hint = entry.authorHint
?? entry.note
?? 'Remove it — it is declared in the spec but not consumed at runtime.';
findings.push({
where: whereBase,
message: `sets \`${path}\` but this ${type} property ${kind}.`,
hint,
rule,
});
}
}

/** Resolve a dotted path one or more levels, treating a missing parent as absent. */
function getNested(obj: AnyRec, path: string): unknown {
let cur: unknown = obj;
for (const seg of path.split('.')) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as AnyRec)[seg];
}
return cur;
}

/**
* Lint the compiled stack for authored properties the liveness ledger flags as
* misleading. Advisory only — returns findings, never throws. v1 covers the two
* highest-signal surfaces (objects incl. their `enable.*` flags, and their
* fields); the mechanism is ledger-driven, so coverage grows by marking more
* entries `authorWarn` rather than touching this code.
*/
export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] {
const dir = resolveLivenessDir();
if (!dir) return [];
const objectWarn = loadWarnMap(dir, 'object');
const fieldWarn = loadWarnMap(dir, 'field');
if (objectWarn.size === 0 && fieldWarn.size === 0) return [];

const findings: LivenessLintFinding[] = [];
for (const obj of asArray(stack.objects)) {
const objName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
if (objectWarn.size > 0) checkItem('object', obj, `object '${objName}'`, objectWarn, findings);
if (fieldWarn.size > 0) {
for (const field of asArray(obj.fields)) {
const fieldName = typeof field.name === 'string' ? field.name : '(unnamed field)';
checkItem('field', field, `object '${objName}' · field '${fieldName}'`, fieldWarn, findings);
}
}
}
return findings;
}
30 changes: 30 additions & 0 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,36 @@ Resolution per property: **ledger entry → spec `.describe()` marker → UNCLAS
Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`,
`protection` — ADR-0010) are auto-classified `live`.

## Author warnings — closing the loop (`authorWarn`)

Classification is also fed back to the *author* at build time. The CLI `compile`
lint (`packages/cli/src/utils/lint-liveness-properties.ts`) reads these ledgers and
emits an advisory **warning** when an authored object/field sets a property that is
misleading — "you set this expecting it to do something; at runtime it does nothing /
isn't enforced" — with a corrective hint. Never fails the build.

Signal over noise is the whole point, so warnings are **opt-in per entry**:

| Field | Effect |
|---|---|
| `"authorWarn": true` | warn when this property is authored (in addition, any `experimental` entry warns by default — it's a declared-but-unenforced guarantee). |
| `"authorHint": "…"` | the corrective one-liner shown under the warning (falls back to `note`). |

Two rules keep it false-positive-free, **both of which the marker author must respect**:

1. **Only mark genuinely *misleading* dead props** — ones that imply a capability/behavior
that doesn't exist (`enable.feeds`, `field.columnName`, `versioning`). Benign display/doc
metadata that's "dead" (no runtime reader) — `description`, `tags`, `icon` — must NOT be
marked; an author isn't misled by them.
2. **Booleans: only mark `default(false)` flags.** The lint warns on a boolean only when set
`true`, and it can't tell author-set-`true` from a schema default. A `default(true)` flag
(`enable.trash`/`mru`, `enable.searchable`) would then warn on *every* object that has an
`enable` block — so leave those unmarked (see `enable.searchable`'s `_authorWarnSkipped`).
Object/string/array props warn when merely present, so this caveat is boolean-only.

The lint is ledger-driven: coverage grows by marking more entries `authorWarn`, not by
touching the lint code. Today it covers `object` (incl. `enable.*`) and `field`.

## Granularity — drill one level

A property is classified at the top level by default. A **container** property (object /
Expand Down
24 changes: 18 additions & 6 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,22 @@
},
"referenceFilters": {
"status": "dead",
"evidence": "lookup dialog reads lookup_filters (LookupField.tsx:171) — entirely dead as authored (naming drift)"
"evidence": "lookup dialog reads lookup_filters (LookupField.tsx:171) — entirely dead as authored (naming drift)",
"authorWarn": true,
"authorHint": "The lookup dialog reads `lookup_filters`, not `referenceFilters` — as authored this filters nothing. Use the supported lookup-filter key, or remove it to avoid implying a constrained lookup."
},
"maxRating": {
"status": "dead",
"evidence": "RatingField reads `max` (RatingField.tsx:13) — dead + redundant with max"
"evidence": "RatingField reads `max` (RatingField.tsx:13) — dead + redundant with max",
"authorWarn": true,
"authorHint": "The rating renderer reads `max`, not `maxRating` — set `max` instead; `maxRating` is ignored."
},
"columnName": {
"status": "dead",
"evidence": "resolveColumnName (spec system-names.ts:182) has ZERO call sites; SQL driver hardcodes column = field key",
"note": "DANGEROUS — advertises custom physical columns the driver never honors."
"note": "DANGEROUS — advertises custom physical columns the driver never honors.",
"authorWarn": true,
"authorHint": "The physical column always equals the field key — a custom `columnName` is silently ignored by the driver. Remove it; rename the field key itself if you need a different column."
},
"searchable": {
"status": "live",
Expand All @@ -167,7 +173,9 @@
},
"index": {
"status": "dead",
"evidence": "field-level — driver reads object indexes[] (sql-driver.ts:1252); field bool unused"
"evidence": "field-level — driver reads object indexes[] (sql-driver.ts:1252); field bool unused",
"authorWarn": true,
"authorHint": "A field-level `index: true` creates no index — the driver builds indexes from the object's `indexes[]` array. Declare the index there instead."
},
"externalId": {
"status": "live",
Expand All @@ -181,11 +189,15 @@
},
"vectorConfig": {
"status": "dead",
"evidence": "nested config — renderers read flat dimensions; no consumer, no vector-index DDL"
"evidence": "nested config — renderers read flat dimensions; no consumer, no vector-index DDL",
"authorWarn": true,
"authorHint": "This nested block is not read (no vector-index DDL). Set the flat `dimensions` sibling instead."
},
"fileAttachmentConfig": {
"status": "dead",
"evidence": "nested config — renderers read flat multiple/accept/maxSize (FileField.tsx:16); no size/type/virus enforcement in write path"
"evidence": "nested config — renderers read flat multiple/accept/maxSize (FileField.tsx:16); no size/type/virus enforcement in write path",
"authorWarn": true,
"authorHint": "This nested block is not read — and note no size/type enforcement happens on write. Use the flat `multiple`/`accept`/`maxSize` siblings for the renderer."
},
"dependencies": {
"status": "dead",
Expand Down
Loading
Loading