-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcheck-liveness.mjs
More file actions
187 lines (172 loc) · 8.58 KB
/
Copy pathcheck-liveness.mjs
File metadata and controls
187 lines (172 loc) · 8.58 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
#!/usr/bin/env node
// Spec liveness gate.
//
// For a metadata-driven platform the spec IS the product surface: authors write
// metadata against these schemas. A property that is parsed but has no runtime
// consumer is a silent no-op (worst case: a *security* no-op — "false compliance").
// The metadata-liveness audits (docs/audits/2026-06-*-property-liveness.md) found
// large swaths of such DEAD props. This gate makes the classification explicit and
// keeps it from regressing: in a GOVERNED category, every authorable property must
// declare a liveness status with evidence, or CI fails.
//
// Statuses (per property, or per schema via "_schema"):
// live — has a runtime consumer (cite it in `evidence`: file:line / test)
// experimental| planned — declared, intentionally not enforced yet (mark in spec
// `.describe()` as "[EXPERIMENTAL — not enforced]" or via the ledger)
// dead — parsed, no consumer; tracked for enforce-or-remove (cite the audit)
// internal — (schema-level only) not authorable metadata (runtime result/DTO,
// enum, helper); exempt from property classification
//
// Resolution order per property: ledger entry > spec `.describe()` marker > UNCLASSIFIED
// A GOVERNED category with any UNCLASSIFIED property → non-zero exit (the ratchet:
// you cannot add a new spec property without classifying it).
//
// Usage:
// node check-liveness.mjs # check all governed categories
// node check-liveness.mjs --dump security # inventory a category's props (seeding aid)
// node check-liveness.mjs --json # machine-readable report
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const specRoot = resolve(here, '../..'); // packages/spec
const repoRoot = resolve(specRoot, '../..'); // repo root
const schemaRoot = join(specRoot, 'json-schema');
const ledgerRoot = join(specRoot, 'liveness');
// Categories whose authorable schemas must be fully classified. Extend
// highest-risk-first as each category's ledger is seeded from its audit.
const GOVERNED = ['security', 'identity', 'ai'];
const args = process.argv.slice(2);
const asJson = args.includes('--json');
const dumpIdx = args.indexOf('--dump');
const dumpCategory = dumpIdx !== -1 ? args[dumpIdx + 1] : null;
const MARKER_RE = {
experimental: /\[experimental|not enforced|aspirational/i,
planned: /\[planned|not yet implemented|coming soon/i,
};
// Framework-managed provenance/lock overlay fields (ADR-0010 + metadata
// provenance stamping). These appear on EVERY authorable metadata type, are
// system-stamped (not type-specific authorable surface), and are consumed by the
// overlay/lock loader. Auto-classified `live` so each category's ledger doesn't
// have to repeat them.
const FRAMEWORK_FIELDS = new Set([
'_lock', '_lockReason', '_lockSource', '_lockDocsUrl',
'_provenance', '_packageId', '_packageVersion', 'protection',
]);
function loadSchemas(category) {
const dir = join(schemaRoot, category);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith('.json'))
.map((f) => {
const schema = JSON.parse(readFileSync(join(dir, f), 'utf8'));
return { name: f.replace('.json', ''), schema };
});
}
// top-level authorable properties of an object schema (one level; a nested object
// property represents its whole subtree, matching how the audits reason)
function topProps(schema) {
if (!schema || schema.type !== 'object' || !schema.properties) return [];
return Object.entries(schema.properties).map(([key, def]) => ({
key,
description: (def && def.description) || '',
}));
}
function markerStatus(description) {
if (MARKER_RE.planned.test(description)) return 'planned';
if (MARKER_RE.experimental.test(description)) return 'experimental';
return null;
}
function loadLedger(category) {
const f = join(ledgerRoot, `${category}.json`);
if (!existsSync(f)) return null;
return JSON.parse(readFileSync(f, 'utf8'));
}
if (dumpCategory) {
const rows = [];
for (const { name, schema } of loadSchemas(dumpCategory)) {
const kind = schema.type === 'object' && schema.properties ? 'object' : (schema.enum ? 'enum' : schema.type || 'other');
if (kind !== 'object') { rows.push({ schema: name, kind, prop: '', marker: '' }); continue; }
for (const p of topProps(schema)) rows.push({ schema: name, kind, prop: p.key, marker: markerStatus(p.description) || '' });
}
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
process.exit(0);
}
const report = { categories: {}, totals: { classified: 0, unclassified: 0, byStatus: {} }, unclassified: [], staleEvidence: [] };
for (const category of GOVERNED) {
const ledger = loadLedger(category) || { schemas: {} };
// Two governance modes:
// - default: every authorable object schema in the category must be classified
// (`internal` exempts non-authorable ones). Right for clean, fully-authorable
// categories (security, identity).
// - allowlist (`"mode":"allowlist"` + `"governed":[...]`): only the named schemas
// are checked; the rest of the category is out of scope. Right for categories
// dominated by protocol/engine DTOs where the authorable types are a small
// subset (ai: Agent/Tool/Skill among embedder/knowledge/model DTOs).
const allowlist = ledger.mode === 'allowlist' ? new Set(ledger.governed || []) : null;
const cat = { classified: 0, unclassified: 0, byStatus: {} };
const seen = new Set();
for (const { name, schema } of loadSchemas(category)) {
if (allowlist && !allowlist.has(name)) continue; // allowlist: skip un-governed schemas
seen.add(name);
const props = topProps(schema);
if (props.length === 0) continue; // enums / scalars carry no authorable props
const entry = ledger.schemas[name] || {};
if (entry._schema === 'internal') continue; // exempt: not authorable
for (const { key, description } of props) {
if (FRAMEWORK_FIELDS.has(key)) { // ADR-0010 provenance/lock — auto-live
cat.classified++; cat.byStatus.live = (cat.byStatus.live || 0) + 1;
report.totals.byStatus.live = (report.totals.byStatus.live || 0) + 1;
continue;
}
const led = entry.props && entry.props[key];
const status = (led && led.status) || entry._schema || markerStatus(description);
if (!status) {
cat.unclassified++;
report.unclassified.push(`${category}/${name}.${key}`);
continue;
}
cat.classified++;
cat.byStatus[status] = (cat.byStatus[status] || 0) + 1;
report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1;
// soft evidence sanity for `live`
if (status === 'live' && led && led.evidence) {
const file = String(led.evidence).split(':')[0];
if (/\//.test(file) && !existsSync(join(repoRoot, file))) report.staleEvidence.push(`${category}/${name}.${key} → ${led.evidence}`);
}
}
}
// allowlist sanity: a governed schema name that no longer exists is a typo or a
// rename that silently drops coverage — surface it as unclassified.
if (allowlist) {
for (const want of allowlist) {
if (!seen.has(want)) {
cat.unclassified++;
report.unclassified.push(`${category}/${want} (governed schema not found — renamed or removed?)`);
}
}
}
report.categories[category] = cat;
report.totals.classified += cat.classified;
report.totals.unclassified += cat.unclassified;
}
if (asJson) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
console.log('Spec liveness gate — governed categories:', GOVERNED.join(', '));
for (const [c, v] of Object.entries(report.categories)) {
const parts = Object.entries(v.byStatus).map(([s, n]) => `${s} ${n}`).join(', ');
console.log(` ${c}: ${v.classified} classified (${parts || '—'}), ${v.unclassified} unclassified`);
}
if (report.staleEvidence.length) {
console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file (evidence may be stale):`);
report.staleEvidence.forEach((s) => console.log(` ${s}`));
}
if (report.unclassified.length) {
console.log(`\n✗ ${report.unclassified.length} UNCLASSIFIED propert(ies) in governed categories — classify in packages/spec/liveness/<category>.json:`);
report.unclassified.forEach((s) => console.log(` ${s}`));
} else {
console.log('\n✓ all governed-category properties are classified.');
}
}
process.exit(report.unclassified.length > 0 ? 1 : 0);