Skip to content

Commit 90108e0

Browse files
os-zhuangclaude
andauthored
feat(cli): liveness author-warning lint — close the spec-liveness loop (#1966)
The liveness ledgers classify every authorable property live/experimental/ dead with evidence, and the CI gate enforces classification completeness — but that knowledge never reached the author (very often an AI) writing the metadata. This feeds it back at build time. New `compile` lint (lint-liveness-properties.ts) reads the ledgers and warns when an authored object/field sets a misleading property — e.g. `object.enable.feeds` (no feed runtime), `object.versioning` (no engine), `field.columnName` (driver ignores it), `field.maxRating`/`vectorConfig` (renderer reads a different key) — with a corrective hint. Advisory only; never fails the build, like the existing flow anti-pattern lint. Signal-over-noise by design: opt-in per ledger entry via a new `authorWarn`/`authorHint` annotation (experimental entries warn by default). Booleans warn only when truthy and only `default(false)` flags are marked, so schema defaults (enable.trash/searchable) never trip it. Coverage grows by annotating more entries, not by touching lint code. - spec: ledger entries gain optional authorWarn/authorHint; `liveness/` now shipped in package `files` so the CLI can read it. Seeded the misleading object capability flags + aspirational blocks and dead field props. - README documents the authorWarn convention + the default-false caveat. Verified end-to-end via a real `objectstack compile` (warnings fired for enable.feeds/versioning/columnName; the lint also caught two genuine pre-existing dead-prop usages in app-showcase). CLI 451 + 9 new lint tests green; liveness gate green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 13a1f97 commit 90108e0

8 files changed

Lines changed: 370 additions & 18 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(cli): liveness author-warning lint — close the spec-liveness loop on the author side.
7+
8+
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.
9+
10+
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`.
11+
12+
- `@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.

packages/cli/src/commands/compile.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '../utils/validate-expressions.js';
1212
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
1313
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
14+
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
1415
import { collectAndLintDocs } from '../utils/collect-docs.js';
1516
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
1617
import {
@@ -208,6 +209,22 @@ export default class Compile extends Command {
208209
}
209210
}
210211

212+
// 3d-bis. Liveness author-warning lint — close the spec-liveness loop on
213+
// the author side: an authored property the ledger marks dead-and-
214+
// misleading (e.g. `object.enable.feeds`, `field.columnName`) or
215+
// experimental is set hopefully but does nothing / isn't enforced at
216+
// runtime. Advisory only; ledger-driven (entries opt in via
217+
// `authorWarn`), so it's high-signal and NEVER fails the build.
218+
const livenessLint = lintLivenessProperties(result.data as Record<string, unknown>);
219+
if (livenessLint.length > 0 && !flags.json) {
220+
console.log('');
221+
for (const fnd of livenessLint) {
222+
printWarning(`${fnd.where}: ${fnd.message}`);
223+
console.log(chalk.dim(` ${fnd.hint}`));
224+
console.log(chalk.dim(` rule: ${fnd.rule}`));
225+
}
226+
}
227+
211228
// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
212229
// `docs: DocSchema[]` and lint the combined set (flatness,
213230
// namespace-prefixed names, MDX/image ban, same-package link
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
lintLivenessProperties,
6+
LIVENESS_DEAD_PROPERTY,
7+
} from './lint-liveness-properties.js';
8+
9+
/**
10+
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
11+
* files the gate enforces), so they double as a contract test: if an
12+
* `authorWarn` annotation is removed from `enable.feeds` / `columnName` / etc.,
13+
* the matching assertion fails.
14+
*/
15+
16+
const objStack = (obj: Record<string, unknown>) => ({ objects: [{ name: 'widget', ...obj }] });
17+
const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
18+
const paths = (findings: { message: string }[]) => findings.map((f) => f.message);
19+
20+
describe('lintLivenessProperties', () => {
21+
it('warns on an authored dead capability flag (enable.feeds: true)', () => {
22+
const findings = lintLivenessProperties(objStack({ enable: { feeds: true } }));
23+
expect(findings.length).toBeGreaterThan(0);
24+
const feeds = findings.find((f) => f.message.includes('enable.feeds'));
25+
expect(feeds).toBeDefined();
26+
expect(feeds!.rule).toBe(LIVENESS_DEAD_PROPERTY);
27+
expect(feeds!.where).toBe("object 'widget'");
28+
expect(feeds!.hint.length).toBeGreaterThan(0);
29+
});
30+
31+
it('does NOT warn on a default-on flag the author left alone (enable.trash: true)', () => {
32+
const findings = lintLivenessProperties(objStack({ enable: { trash: true } }));
33+
expect(paths(findings).some((m) => m.includes('enable.trash'))).toBe(false);
34+
});
35+
36+
it('does NOT warn when a dead boolean flag is explicitly false (enable.files: false)', () => {
37+
const findings = lintLivenessProperties(objStack({ enable: { files: false } }));
38+
expect(paths(findings).some((m) => m.includes('enable.files'))).toBe(false);
39+
});
40+
41+
it('warns on a present dead object block (versioning)', () => {
42+
const findings = lintLivenessProperties(objStack({ versioning: { enabled: true } }));
43+
expect(paths(findings).some((m) => m.includes('versioning'))).toBe(true);
44+
});
45+
46+
it('warns on a misleading dead field prop (columnName)', () => {
47+
const findings = lintLivenessProperties(
48+
objStack({ fields: [{ name: 'code', type: 'text', columnName: 'legacy_code' }] }),
49+
);
50+
const f = findings.find((x) => x.message.includes('columnName'));
51+
expect(f).toBeDefined();
52+
expect(f!.where).toBe("object 'widget' · field 'code'");
53+
});
54+
55+
it('warns on a field-level index flag set true, but not when false', () => {
56+
const on = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: true }] }));
57+
expect(paths(on).some((m) => m.includes('`index`'))).toBe(true);
58+
const off = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: false }] }));
59+
expect(paths(off).some((m) => m.includes('`index`'))).toBe(false);
60+
});
61+
62+
it('is silent for a clean object with only live properties', () => {
63+
const findings = lintLivenessProperties(
64+
objStack({
65+
label: 'Widget',
66+
enable: { apiEnabled: true },
67+
fields: [{ name: 'name', type: 'text', label: 'Name' }],
68+
}),
69+
);
70+
expect(findings).toEqual([]);
71+
});
72+
73+
it('handles objects as a keyed record (not just arrays)', () => {
74+
const findings = lintLivenessProperties({
75+
objects: { widget: { name: 'widget', enable: { feeds: true } } },
76+
});
77+
expect(rules(findings)).toContain(LIVENESS_DEAD_PROPERTY);
78+
});
79+
80+
it('returns [] on an empty / shapeless stack', () => {
81+
expect(lintLivenessProperties({})).toEqual([]);
82+
expect(lintLivenessProperties({ objects: [] })).toEqual([]);
83+
});
84+
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Build-time lint that closes the spec-liveness loop on the AUTHOR side.
5+
*
6+
* The liveness ledgers (`@objectstack/spec/liveness/<type>.json`) classify every
7+
* authorable metadata property as live / experimental / dead with evidence. The
8+
* CI gate enforces that classification is *complete*, but the ledger's knowledge
9+
* never reached the person (very often an AI) writing the metadata. This lint
10+
* surfaces it: when an authored object/field sets a property the ledger marks as
11+
* dead-and-misleading (or experimental), it emits an advisory WARNING — "you set
12+
* this expecting it to do something; at runtime it does nothing" — with a hint
13+
* toward the supported alternative. It NEVER fails the build.
14+
*
15+
* Signal over noise is the whole point, so the ledger opts in per entry via
16+
* `"authorWarn": true` (+ an optional `"authorHint"`). A property being merely
17+
* `dead` is NOT enough — plenty of dead props are benign display/doc metadata.
18+
* Only entries an author would be *misled* by are marked. Booleans warn only when
19+
* set truthy (so schema defaults like `enable.trash` never trip it); object/
20+
* string/array props warn when present at all.
21+
*/
22+
23+
import { createRequire } from 'node:module';
24+
import { dirname, join } from 'node:path';
25+
import { existsSync, readFileSync } from 'node:fs';
26+
27+
export interface LivenessLintFinding {
28+
where: string;
29+
message: string;
30+
hint: string;
31+
rule: string;
32+
}
33+
34+
export const LIVENESS_DEAD_PROPERTY = 'liveness-dead-property';
35+
export const LIVENESS_EXPERIMENTAL_PROPERTY = 'liveness-experimental-property';
36+
37+
type AnyRec = Record<string, unknown>;
38+
39+
interface LedgerEntry {
40+
status?: string;
41+
authorWarn?: boolean;
42+
authorHint?: string;
43+
note?: string;
44+
children?: Record<string, LedgerEntry>;
45+
}
46+
47+
/** Flattened, warn-only view of a type's ledger: propPath → entry (incl. `a.b` children). */
48+
type WarnMap = Map<string, LedgerEntry>;
49+
50+
function asArray(v: unknown): AnyRec[] {
51+
if (Array.isArray(v)) return v as AnyRec[];
52+
if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
53+
return [];
54+
}
55+
56+
/** Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or published files). */
57+
function resolveLivenessDir(): string | null {
58+
try {
59+
const require = createRequire(import.meta.url);
60+
const pkgJson = require.resolve('@objectstack/spec/package.json');
61+
const dir = join(dirname(pkgJson), 'liveness');
62+
return existsSync(dir) ? dir : null;
63+
} catch {
64+
return null;
65+
}
66+
}
67+
68+
/** Build the warn-only lookup for one type, flattening one level of `children`. */
69+
function loadWarnMap(dir: string, type: string): WarnMap {
70+
const map: WarnMap = new Map();
71+
const file = join(dir, `${type}.json`);
72+
if (!existsSync(file)) return map;
73+
let ledger: { props?: Record<string, LedgerEntry> };
74+
try {
75+
ledger = JSON.parse(readFileSync(file, 'utf8'));
76+
} catch {
77+
return map;
78+
}
79+
const props = ledger.props || {};
80+
for (const [key, entry] of Object.entries(props)) {
81+
if (entry?.children) {
82+
for (const [ck, centry] of Object.entries(entry.children)) {
83+
if (shouldWarn(centry)) map.set(`${key}.${ck}`, centry);
84+
}
85+
}
86+
if (shouldWarn(entry)) map.set(key, entry);
87+
}
88+
return map;
89+
}
90+
91+
/** An entry warns when explicitly opted in, OR when it's experimental (a declared-but-unenforced guarantee). */
92+
function shouldWarn(entry: LedgerEntry | undefined): boolean {
93+
if (!entry) return false;
94+
return entry.authorWarn === true || entry.status === 'experimental';
95+
}
96+
97+
/** A value that signals authoring intent: booleans only when truthy; everything else when present. */
98+
function isAuthored(value: unknown): boolean {
99+
if (value === undefined || value === null) return false;
100+
if (typeof value === 'boolean') return value === true;
101+
return true;
102+
}
103+
104+
function describe(entry: LedgerEntry): { kind: string; rule: string } {
105+
if (entry.status === 'experimental') {
106+
return { kind: 'is experimental — declared but NOT enforced at runtime', rule: LIVENESS_EXPERIMENTAL_PROPERTY };
107+
}
108+
return { kind: 'has no runtime effect (liveness: dead)', rule: LIVENESS_DEAD_PROPERTY };
109+
}
110+
111+
/** Check one metadata item's set properties against its type's warn-map. */
112+
function checkItem(
113+
type: string,
114+
item: AnyRec,
115+
whereBase: string,
116+
warnMap: WarnMap,
117+
findings: LivenessLintFinding[],
118+
): void {
119+
for (const [path, entry] of warnMap) {
120+
const value = path.includes('.')
121+
? getNested(item, path)
122+
: item[path];
123+
if (!isAuthored(value)) continue;
124+
const { kind, rule } = describe(entry);
125+
const hint = entry.authorHint
126+
?? entry.note
127+
?? 'Remove it — it is declared in the spec but not consumed at runtime.';
128+
findings.push({
129+
where: whereBase,
130+
message: `sets \`${path}\` but this ${type} property ${kind}.`,
131+
hint,
132+
rule,
133+
});
134+
}
135+
}
136+
137+
/** Resolve a dotted path one or more levels, treating a missing parent as absent. */
138+
function getNested(obj: AnyRec, path: string): unknown {
139+
let cur: unknown = obj;
140+
for (const seg of path.split('.')) {
141+
if (cur === null || typeof cur !== 'object') return undefined;
142+
cur = (cur as AnyRec)[seg];
143+
}
144+
return cur;
145+
}
146+
147+
/**
148+
* Lint the compiled stack for authored properties the liveness ledger flags as
149+
* misleading. Advisory only — returns findings, never throws. v1 covers the two
150+
* highest-signal surfaces (objects incl. their `enable.*` flags, and their
151+
* fields); the mechanism is ledger-driven, so coverage grows by marking more
152+
* entries `authorWarn` rather than touching this code.
153+
*/
154+
export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] {
155+
const dir = resolveLivenessDir();
156+
if (!dir) return [];
157+
const objectWarn = loadWarnMap(dir, 'object');
158+
const fieldWarn = loadWarnMap(dir, 'field');
159+
if (objectWarn.size === 0 && fieldWarn.size === 0) return [];
160+
161+
const findings: LivenessLintFinding[] = [];
162+
for (const obj of asArray(stack.objects)) {
163+
const objName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
164+
if (objectWarn.size > 0) checkItem('object', obj, `object '${objName}'`, objectWarn, findings);
165+
if (fieldWarn.size > 0) {
166+
for (const field of asArray(obj.fields)) {
167+
const fieldName = typeof field.name === 'string' ? field.name : '(unnamed field)';
168+
checkItem('field', field, `object '${objName}' · field '${fieldName}'`, fieldWarn, findings);
169+
}
170+
}
171+
}
172+
return findings;
173+
}

packages/spec/liveness/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,36 @@ Resolution per property: **ledger entry → spec `.describe()` marker → UNCLAS
3535
Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`,
3636
`protection` — ADR-0010) are auto-classified `live`.
3737

38+
## Author warnings — closing the loop (`authorWarn`)
39+
40+
Classification is also fed back to the *author* at build time. The CLI `compile`
41+
lint (`packages/cli/src/utils/lint-liveness-properties.ts`) reads these ledgers and
42+
emits an advisory **warning** when an authored object/field sets a property that is
43+
misleading — "you set this expecting it to do something; at runtime it does nothing /
44+
isn't enforced" — with a corrective hint. Never fails the build.
45+
46+
Signal over noise is the whole point, so warnings are **opt-in per entry**:
47+
48+
| Field | Effect |
49+
|---|---|
50+
| `"authorWarn": true` | warn when this property is authored (in addition, any `experimental` entry warns by default — it's a declared-but-unenforced guarantee). |
51+
| `"authorHint": "…"` | the corrective one-liner shown under the warning (falls back to `note`). |
52+
53+
Two rules keep it false-positive-free, **both of which the marker author must respect**:
54+
55+
1. **Only mark genuinely *misleading* dead props** — ones that imply a capability/behavior
56+
that doesn't exist (`enable.feeds`, `field.columnName`, `versioning`). Benign display/doc
57+
metadata that's "dead" (no runtime reader) — `description`, `tags`, `icon` — must NOT be
58+
marked; an author isn't misled by them.
59+
2. **Booleans: only mark `default(false)` flags.** The lint warns on a boolean only when set
60+
`true`, and it can't tell author-set-`true` from a schema default. A `default(true)` flag
61+
(`enable.trash`/`mru`, `enable.searchable`) would then warn on *every* object that has an
62+
`enable` block — so leave those unmarked (see `enable.searchable`'s `_authorWarnSkipped`).
63+
Object/string/array props warn when merely present, so this caveat is boolean-only.
64+
65+
The lint is ledger-driven: coverage grows by marking more entries `authorWarn`, not by
66+
touching the lint code. Today it covers `object` (incl. `enable.*`) and `field`.
67+
3868
## Granularity — drill one level
3969

4070
A property is classified at the top level by default. A **container** property (object /

packages/spec/liveness/field.json

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,22 @@
149149
},
150150
"referenceFilters": {
151151
"status": "dead",
152-
"evidence": "lookup dialog reads lookup_filters (LookupField.tsx:171) — entirely dead as authored (naming drift)"
152+
"evidence": "lookup dialog reads lookup_filters (LookupField.tsx:171) — entirely dead as authored (naming drift)",
153+
"authorWarn": true,
154+
"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."
153155
},
154156
"maxRating": {
155157
"status": "dead",
156-
"evidence": "RatingField reads `max` (RatingField.tsx:13) — dead + redundant with max"
158+
"evidence": "RatingField reads `max` (RatingField.tsx:13) — dead + redundant with max",
159+
"authorWarn": true,
160+
"authorHint": "The rating renderer reads `max`, not `maxRating` — set `max` instead; `maxRating` is ignored."
157161
},
158162
"columnName": {
159163
"status": "dead",
160164
"evidence": "resolveColumnName (spec system-names.ts:182) has ZERO call sites; SQL driver hardcodes column = field key",
161-
"note": "DANGEROUS — advertises custom physical columns the driver never honors."
165+
"note": "DANGEROUS — advertises custom physical columns the driver never honors.",
166+
"authorWarn": true,
167+
"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."
162168
},
163169
"searchable": {
164170
"status": "live",
@@ -167,7 +173,9 @@
167173
},
168174
"index": {
169175
"status": "dead",
170-
"evidence": "field-level — driver reads object indexes[] (sql-driver.ts:1252); field bool unused"
176+
"evidence": "field-level — driver reads object indexes[] (sql-driver.ts:1252); field bool unused",
177+
"authorWarn": true,
178+
"authorHint": "A field-level `index: true` creates no index — the driver builds indexes from the object's `indexes[]` array. Declare the index there instead."
171179
},
172180
"externalId": {
173181
"status": "live",
@@ -181,11 +189,15 @@
181189
},
182190
"vectorConfig": {
183191
"status": "dead",
184-
"evidence": "nested config — renderers read flat dimensions; no consumer, no vector-index DDL"
192+
"evidence": "nested config — renderers read flat dimensions; no consumer, no vector-index DDL",
193+
"authorWarn": true,
194+
"authorHint": "This nested block is not read (no vector-index DDL). Set the flat `dimensions` sibling instead."
185195
},
186196
"fileAttachmentConfig": {
187197
"status": "dead",
188-
"evidence": "nested config — renderers read flat multiple/accept/maxSize (FileField.tsx:16); no size/type/virus enforcement in write path"
198+
"evidence": "nested config — renderers read flat multiple/accept/maxSize (FileField.tsx:16); no size/type/virus enforcement in write path",
199+
"authorWarn": true,
200+
"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."
189201
},
190202
"dependencies": {
191203
"status": "dead",

0 commit comments

Comments
 (0)