-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathlint-liveness-properties.ts
More file actions
227 lines (206 loc) · 8.4 KB
/
Copy pathlint-liveness-properties.ts
File metadata and controls
227 lines (206 loc) · 8.4 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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
// 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.searchable` 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 values = path.includes('.')
? getNested(item, path)
: [item[path]];
for (const value of values instanceof Array ? values : [values]) {
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,
});
break; // one finding per (item, path) even when the container is an array
}
}
}
/**
* Resolve a dotted path one or more levels, treating a missing parent as
* absent. A container level that is an ARRAY fans out over its elements
* (e.g. `nodes.outputSchema` on a flow checks every node), returning the
* list of resolved values.
*/
function getNested(obj: AnyRec, path: string): unknown[] {
let cur: unknown[] = [obj];
for (const seg of path.split('.')) {
const next: unknown[] = [];
for (const c of cur) {
if (c === null || typeof c !== 'object') continue;
const v = Array.isArray(c) ? undefined : (c as AnyRec)[seg];
if (Array.isArray(c)) {
for (const el of c) {
if (el && typeof el === 'object') next.push((el as AnyRec)[seg]);
}
} else {
next.push(v);
}
}
cur = next;
}
// Final level may itself contain arrays-of-values; flatten one step so a
// trailing array container (e.g. `measures` → each measure) fans out too.
return cur.flatMap((v) => (Array.isArray(v) ? v : [v]));
}
/**
* The compiled-stack collection each governed metadata type lives in.
* `object`/`field` keep their bespoke walk (fields nest under objects);
* everything else is a flat top-level array on the stack definition.
*/
const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
{ type: 'flow', key: 'flows' },
{ type: 'action', key: 'actions' },
{ type: 'agent', key: 'agents' },
{ type: 'tool', key: 'tools' },
{ type: 'skill', key: 'skills' },
{ type: 'dataset', key: 'datasets' },
{ type: 'permission', key: 'permissions' },
{ type: 'hook', key: 'hooks' },
{ type: 'page', key: 'pages' },
{ type: 'view', key: 'views' },
];
/**
* Lint the compiled stack for authored properties the liveness ledger flags as
* misleading. Advisory only — returns findings, never throws. Covers every
* governed metadata type: objects (incl. `enable.*`) and their fields walk
* bespoke nesting; the remaining types are flat stack collections. Container
* properties fan out over arrays (each flow node, each dataset measure). The
* mechanism stays ledger-driven — 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 findings: LivenessLintFinding[] = [];
const objectWarn = loadWarnMap(dir, 'object');
const fieldWarn = loadWarnMap(dir, 'field');
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);
}
}
}
for (const { type, key } of TYPE_COLLECTIONS) {
const warnMap = loadWarnMap(dir, type);
if (warnMap.size === 0) continue;
for (const item of asArray(stack[key])) {
// view containers bind via `object`, not `name`
const name = typeof item.name === 'string' ? item.name
: typeof item.object === 'string' ? item.object
: `(unnamed ${type})`;
checkItem(type, item, `${type} '${name}'`, warnMap, findings);
}
}
return findings;
}