|
| 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 | +} |
0 commit comments