-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathlint-liveness-properties.ts
More file actions
173 lines (156 loc) · 6.38 KB
/
Copy pathlint-liveness-properties.ts
File metadata and controls
173 lines (156 loc) · 6.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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;
}