@codyswann/lisa 2.345.1 → 2.346.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/copy-overwrite/scripts/check-state-classification.mjs +609 -0
- package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +194 -0
- package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +208 -0
- package/all/copy-overwrite/scripts/schemas/lisa-command-envelope.v1.schema.json +119 -0
- package/all/copy-overwrite/scripts/schemas/lisa-state-contract.v1.schema.json +191 -0
- package/all/create-only/state/README.md +41 -0
- package/all/create-only/state/state-contract.example.json +46 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +56 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-acceptance-criteria/SKILL.md +1 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-codify-verification/SKILL.md +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-research/SKILL.md +7 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-task-decomposition/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-test-strategy/SKILL.md +1 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-verification-lifecycle/SKILL.md +1 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-verify/SKILL.md +1 -1
- package/plugins/lisa/rules/eager/reset-seed-coverage.md +48 -0
- package/plugins/lisa/rules/reference/reset-seed-coverage.md +231 -0
- package/plugins/lisa/skills/lisa-acceptance-criteria/SKILL.md +1 -0
- package/plugins/lisa/skills/lisa-codify-verification/SKILL.md +4 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-research/SKILL.md +7 -1
- package/plugins/lisa/skills/lisa-task-decomposition/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-test-strategy/SKILL.md +1 -0
- package/plugins/lisa/skills/lisa-verification-lifecycle/SKILL.md +1 -0
- package/plugins/lisa/skills/lisa-verify/SKILL.md +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-acceptance-criteria/SKILL.md +1 -0
- package/plugins/lisa-agy/skills/lisa-codify-verification/SKILL.md +4 -0
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-research/SKILL.md +7 -1
- package/plugins/lisa-agy/skills/lisa-task-decomposition/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-test-strategy/SKILL.md +1 -0
- package/plugins/lisa-agy/skills/lisa-verification-lifecycle/SKILL.md +1 -0
- package/plugins/lisa-agy/skills/lisa-verify/SKILL.md +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/reset-seed-coverage.md +48 -0
- package/plugins/lisa-copilot/rules/reference/reset-seed-coverage.md +231 -0
- package/plugins/lisa-copilot/skills/lisa-acceptance-criteria/SKILL.md +1 -0
- package/plugins/lisa-copilot/skills/lisa-codify-verification/SKILL.md +4 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-research/SKILL.md +7 -1
- package/plugins/lisa-copilot/skills/lisa-task-decomposition/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-test-strategy/SKILL.md +1 -0
- package/plugins/lisa-copilot/skills/lisa-verification-lifecycle/SKILL.md +1 -0
- package/plugins/lisa-copilot/skills/lisa-verify/SKILL.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/reset-seed-coverage-reference.mdc +236 -0
- package/plugins/lisa-cursor/rules/reset-seed-coverage.mdc +53 -0
- package/plugins/lisa-cursor/skills/lisa-acceptance-criteria/SKILL.md +1 -0
- package/plugins/lisa-cursor/skills/lisa-codify-verification/SKILL.md +4 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-research/SKILL.md +7 -1
- package/plugins/lisa-cursor/skills/lisa-task-decomposition/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-test-strategy/SKILL.md +1 -0
- package/plugins/lisa-cursor/skills/lisa-verification-lifecycle/SKILL.md +1 -0
- package/plugins/lisa-cursor/skills/lisa-verify/SKILL.md +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/skills/ops-db-ops/SKILL.md +93 -2
- package/plugins/lisa-expo/skills/ops-db-ops/SKILL.md +94 -3
- package/plugins/lisa-expo/skills/ops-db-ops/agents/openai.yaml +2 -2
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-agy/skills/ops-db-ops/SKILL.md +94 -3
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/skills/ops-db-ops/SKILL.md +94 -3
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/skills/ops-db-ops/SKILL.md +94 -3
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/reset-seed-coverage.md +48 -0
- package/plugins/src/base/rules/reference/reset-seed-coverage.md +231 -0
- package/plugins/src/base/skills/lisa-acceptance-criteria/SKILL.md +1 -0
- package/plugins/src/base/skills/lisa-codify-verification/SKILL.md +4 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-research/SKILL.md +7 -1
- package/plugins/src/base/skills/lisa-task-decomposition/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-test-strategy/SKILL.md +1 -0
- package/plugins/src/base/skills/lisa-verification-lifecycle/SKILL.md +1 -0
- package/plugins/src/base/skills/lisa-verify/SKILL.md +1 -1
- package/plugins/src/expo/skills/ops-db-ops/SKILL.md +94 -3
- package/scripts/check-state-classification.mjs +27 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-state-classification — the anti-drift half of the `reset-seed-coverage`
|
|
4
|
+
* rule.
|
|
5
|
+
*
|
|
6
|
+
* The failure this exists to produce: a work item adds a persistent entity, a
|
|
7
|
+
* suite creates records in it, nothing sweeps them, nothing complains, and the
|
|
8
|
+
* leak surfaces months later as an unreproducible flake. This check compares
|
|
9
|
+
* what the running system actually holds (the **inventory**) against what the
|
|
10
|
+
* repository has decided about (the **state contract**) and **fails closed on
|
|
11
|
+
* any entity nobody classified**.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately NOT "new entities are cleared by default". That model
|
|
14
|
+
* erases unrelated non-production data the first time a change adds an entity,
|
|
15
|
+
* and a migrations-directory diff cannot model renames, multiple schemas,
|
|
16
|
+
* framework-generated entities, views, partitions, row-level ownership, or any
|
|
17
|
+
* state that is not a row. Subtraction survives here as a *detector*: it tells
|
|
18
|
+
* you the contract is stale, it never decides what may be deleted.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node scripts/check-state-classification.mjs
|
|
22
|
+
* node scripts/check-state-classification.mjs --inventory state/inventory.json
|
|
23
|
+
* node scripts/check-state-classification.mjs --contract state/state-contract.json
|
|
24
|
+
*
|
|
25
|
+
* Env:
|
|
26
|
+
* STATE_CONTRACT_FILE override the contract path
|
|
27
|
+
* STATE_INVENTORY_FILE override the inventory path
|
|
28
|
+
* STATE_ROOT project root to scan (default: cwd)
|
|
29
|
+
*
|
|
30
|
+
* Exit 0 = every entity in the inventory is classified and every policy
|
|
31
|
+
* obligation is met (status `completed`), the repo has not adopted the
|
|
32
|
+
* contract yet (`not-adopted`), or the repo's declared noop verified
|
|
33
|
+
* (`no-op`).
|
|
34
|
+
* Exit 1 = anything else, including "no inventory available" — exit 0 must
|
|
35
|
+
* never mean "I could not look".
|
|
36
|
+
* @module scripts/check-state-classification
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from "node:fs";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
buildEnvelope,
|
|
44
|
+
emitEnvelope,
|
|
45
|
+
redact,
|
|
46
|
+
} from "./lisa-command-envelope.mjs";
|
|
47
|
+
import { validateAgainstSchema } from "./lisa-schema-validate.mjs";
|
|
48
|
+
|
|
49
|
+
const SCHEMA_PATH = path.join(
|
|
50
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
51
|
+
"schemas",
|
|
52
|
+
"lisa-state-contract.v1.schema.json"
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** The published state-contract schema, read from the shipped JSON document. */
|
|
56
|
+
export const STATE_CONTRACT_SCHEMA = JSON.parse(
|
|
57
|
+
fs.readFileSync(SCHEMA_PATH, "utf8")
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/** Default contract location; a project may relocate it and record that in .lisa.config.json. */
|
|
61
|
+
export const DEFAULT_CONTRACT_PATH = "state/state-contract.json";
|
|
62
|
+
|
|
63
|
+
/** Default inventory location, produced by the project's `state:inventory` adapter. */
|
|
64
|
+
export const DEFAULT_INVENTORY_PATH = "state/inventory.json";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Assurances every real contract must declare with evidence.
|
|
68
|
+
* @see reset-seed-coverage rule — "Required assurances"
|
|
69
|
+
*/
|
|
70
|
+
export const REQUIRED_ASSURANCES = Object.freeze([
|
|
71
|
+
"preserves-non-fixture-data",
|
|
72
|
+
"rejects-reserved-id-collision",
|
|
73
|
+
"rejects-foreign-references",
|
|
74
|
+
"requires-write-acknowledgment",
|
|
75
|
+
"converges-on-second-apply",
|
|
76
|
+
"verifies-exact-counts",
|
|
77
|
+
"production-fails-closed",
|
|
78
|
+
"guard-at-the-choke-point",
|
|
79
|
+
"enumerates-before-mutating",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Directory names never worth scanning for persistence signals. */
|
|
83
|
+
const SKIP_DIRECTORIES = new Set([
|
|
84
|
+
".git",
|
|
85
|
+
"node_modules",
|
|
86
|
+
"dist",
|
|
87
|
+
"build",
|
|
88
|
+
"coverage",
|
|
89
|
+
".next",
|
|
90
|
+
".expo",
|
|
91
|
+
"vendor",
|
|
92
|
+
"target",
|
|
93
|
+
"tmp",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Repository shapes that mean "this project holds persistent state".
|
|
98
|
+
*
|
|
99
|
+
* Used ONLY to reject an unfounded `declared-noop` — a repo with persistence
|
|
100
|
+
* may not declare that reset does not apply to it. Deliberately broad and
|
|
101
|
+
* deliberately not authoritative for anything else.
|
|
102
|
+
*/
|
|
103
|
+
const PERSISTENCE_SIGNALS = Object.freeze([
|
|
104
|
+
{ kind: "sql", pattern: /\.sql$/iu },
|
|
105
|
+
{ kind: "migration", pattern: /(^|\/)migrations?(\/|$)/iu },
|
|
106
|
+
{ kind: "prisma-schema", pattern: /(^|\/)schema\.prisma$/iu },
|
|
107
|
+
{ kind: "orm-entity", pattern: /\.entity\.[cm]?[jt]s$/iu },
|
|
108
|
+
{ kind: "orm-model", pattern: /(^|\/)models?\/[^/]+\.[cm]?[jt]s$/iu },
|
|
109
|
+
{ kind: "schema-definition", pattern: /(^|\/)db\/schema\.[cm]?[jt]s$/iu },
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/** `CREATE TABLE`-shaped declarations the subtraction detector recognizes. */
|
|
113
|
+
const CREATE_ENTITY_PATTERN =
|
|
114
|
+
/create\s+(?:unlogged\s+|temporary\s+|temp\s+|global\s+|local\s+)*(?:materialized\s+view|table|view)\s+(?:if\s+not\s+exists\s+)?([\w."`[\]]+)/giu;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read a JSON file, returning a discriminated result rather than throwing.
|
|
118
|
+
* @param {string} file - Absolute path
|
|
119
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, missing: boolean, error: string }} Result
|
|
120
|
+
*/
|
|
121
|
+
export function readJsonFile(file) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(file, "utf8");
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
missing: error.code === "ENOENT",
|
|
129
|
+
error: `cannot read ${file}: ${error.message}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
missing: false,
|
|
138
|
+
error: `${file} is not valid JSON: ${error.message}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Walk a directory tree, yielding repository-relative file paths.
|
|
145
|
+
* @param {string} root - Directory to walk
|
|
146
|
+
* @param {string} [prefix] - Accumulated relative prefix
|
|
147
|
+
* @returns {string[]} Relative paths, POSIX-separated
|
|
148
|
+
*/
|
|
149
|
+
export function listFiles(root, prefix = "") {
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = fs.readdirSync(path.join(root, prefix), { withFileTypes: true });
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const files = [];
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
if (SKIP_DIRECTORIES.has(entry.name) || entry.name.startsWith(".")) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
files.push(...listFiles(root, `${prefix}${entry.name}/`));
|
|
163
|
+
} else if (entry.isFile()) {
|
|
164
|
+
files.push(`${prefix}${entry.name}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return files;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Find repository shapes indicating this project persists state.
|
|
172
|
+
* @param {string} root - Project root
|
|
173
|
+
* @returns {{ kind: string, file: string }[]} Signals found (possibly empty)
|
|
174
|
+
*/
|
|
175
|
+
export function detectPersistenceSignals(root) {
|
|
176
|
+
const signals = [];
|
|
177
|
+
for (const file of listFiles(root)) {
|
|
178
|
+
for (const signal of PERSISTENCE_SIGNALS) {
|
|
179
|
+
if (signal.pattern.test(file)) {
|
|
180
|
+
signals.push({ kind: signal.kind, file });
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return signals;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Normalize an entity name for comparison: strip quoting and any namespace.
|
|
190
|
+
* @param {string} name - Raw entity name
|
|
191
|
+
* @returns {string} Lowercased bare name
|
|
192
|
+
*/
|
|
193
|
+
export function bareName(name) {
|
|
194
|
+
const cleaned = String(name).replace(/["`[\]]/gu, "");
|
|
195
|
+
const segments = cleaned.split(".");
|
|
196
|
+
return segments[segments.length - 1].toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Subtraction detector: entity names declared by checked-in schema sources.
|
|
201
|
+
*
|
|
202
|
+
* A staleness signal only. Anything it finds that the inventory did not return
|
|
203
|
+
* means the inventory is incomplete, which invalidates every other conclusion.
|
|
204
|
+
* @param {string} root - Project root
|
|
205
|
+
* @param {string[]} [extraSources] - Additional relative paths to scan
|
|
206
|
+
* @returns {Map<string, string>} bare entity name → the file that declared it
|
|
207
|
+
*/
|
|
208
|
+
export function detectDeclaredEntities(root, extraSources = []) {
|
|
209
|
+
const declared = new Map();
|
|
210
|
+
const candidates = listFiles(root)
|
|
211
|
+
.filter(file => /\.sql$/iu.test(file))
|
|
212
|
+
.concat(extraSources);
|
|
213
|
+
for (const file of candidates) {
|
|
214
|
+
let contents;
|
|
215
|
+
try {
|
|
216
|
+
contents = fs.readFileSync(path.join(root, file), "utf8");
|
|
217
|
+
} catch {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
for (const match of contents.matchAll(CREATE_ENTITY_PATTERN)) {
|
|
221
|
+
const name = bareName(match[1]);
|
|
222
|
+
if (name && !declared.has(name)) {
|
|
223
|
+
declared.set(name, file);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return declared;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Policy-specific obligations. A classification that does not carry these is a
|
|
232
|
+
* decision that cannot be acted on.
|
|
233
|
+
* @param {object} entity - Contract entity entry
|
|
234
|
+
* @returns {string[]} Findings messages (empty when satisfied)
|
|
235
|
+
*/
|
|
236
|
+
function policyObligationErrors(entity) {
|
|
237
|
+
const errors = [];
|
|
238
|
+
if (entity.policy === "fixture-owned") {
|
|
239
|
+
if (!entity.ownership) {
|
|
240
|
+
errors.push(
|
|
241
|
+
'is "fixture-owned" without an `ownership` predicate — "everything in this entity" is not an ownership boundary'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (!entity.sweptBy) {
|
|
245
|
+
errors.push(
|
|
246
|
+
'is "fixture-owned" with no `sweptBy` routine — an entity the suite creates but nothing removes is exactly the leak this contract exists to catch'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (entity.policy === "derived-rebuild" && !entity.rebuiltBy) {
|
|
251
|
+
errors.push('is "derived-rebuild" with no `rebuiltBy` routine');
|
|
252
|
+
}
|
|
253
|
+
if (entity.policy === "forbidden" && !entity.enforcedBy) {
|
|
254
|
+
errors.push(
|
|
255
|
+
'is "forbidden" with no `enforcedBy` control outside the reset process — a script-only promise dies in the refactor that drops the safe caller'
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return errors;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Compare an inventory against a contract and produce findings.
|
|
263
|
+
* @param {object} args - Comparison inputs
|
|
264
|
+
* @param {object} args.contract - Parsed, schema-valid state contract
|
|
265
|
+
* @param {{ entities: { id: string }[] }} args.inventory - Runtime inventory
|
|
266
|
+
* @param {Map<string, string>} [args.declared] - Subtraction-detector output
|
|
267
|
+
* @returns {{ status: string, findings: object[], summary: object }} Verdict
|
|
268
|
+
*/
|
|
269
|
+
export function compareInventory({
|
|
270
|
+
contract,
|
|
271
|
+
inventory,
|
|
272
|
+
declared = new Map(),
|
|
273
|
+
}) {
|
|
274
|
+
const findings = [];
|
|
275
|
+
const entities = contract.entities ?? [];
|
|
276
|
+
const byBareName = new Map();
|
|
277
|
+
const classified = new Map();
|
|
278
|
+
for (const entity of entities) {
|
|
279
|
+
classified.set(entity.id, entity);
|
|
280
|
+
byBareName.set(bareName(entity.id), entity);
|
|
281
|
+
}
|
|
282
|
+
const waived = new Map(
|
|
283
|
+
(contract.waivers ?? []).map(waiver => [waiver.id, waiver])
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const inventoryIds = new Set();
|
|
287
|
+
for (const item of inventory.entities ?? []) {
|
|
288
|
+
inventoryIds.add(item.id);
|
|
289
|
+
const entity = classified.get(item.id);
|
|
290
|
+
if (entity) {
|
|
291
|
+
for (const message of policyObligationErrors(entity)) {
|
|
292
|
+
findings.push({
|
|
293
|
+
code: "policy-obligation-unmet",
|
|
294
|
+
subject: item.id,
|
|
295
|
+
message,
|
|
296
|
+
severity: "error",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (waived.has(item.id)) {
|
|
302
|
+
findings.push({
|
|
303
|
+
code: "waived-unclassified-entity",
|
|
304
|
+
subject: item.id,
|
|
305
|
+
message: `is waived (${waived.get(item.id).ticket}, recorded ${waived.get(item.id).recordedAt}) — treated as "preserve" and still a finding; a waiver is a dated IOU, never a classification`,
|
|
306
|
+
severity: "warning",
|
|
307
|
+
});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
findings.push({
|
|
311
|
+
code: "unclassified-entity",
|
|
312
|
+
subject: item.id,
|
|
313
|
+
message:
|
|
314
|
+
"exists in the running system but no policy classifies it — fail closed: an unclassified entity is neither safe to keep nor safe to delete. Classify it fixture-owned / preserve / derived-rebuild / forbidden in the state contract.",
|
|
315
|
+
severity: "error",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const entity of entities) {
|
|
320
|
+
if (!inventoryIds.has(entity.id) && !entity.retiredAt) {
|
|
321
|
+
findings.push({
|
|
322
|
+
code: "stale-classification",
|
|
323
|
+
subject: entity.id,
|
|
324
|
+
message:
|
|
325
|
+
"is classified but absent from the inventory — a contract describing a system that no longer exists is not protection. Set `retiredAt` or fix the id.",
|
|
326
|
+
severity: "error",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const ignored = new Set(
|
|
332
|
+
(contract.detection?.ignore ?? []).map(name => bareName(name))
|
|
333
|
+
);
|
|
334
|
+
const inventoryBareNames = new Set([...inventoryIds].map(id => bareName(id)));
|
|
335
|
+
for (const [name, source] of declared) {
|
|
336
|
+
if (ignored.has(name) || inventoryBareNames.has(name)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
findings.push({
|
|
340
|
+
code: "inventory-incomplete",
|
|
341
|
+
subject: name,
|
|
342
|
+
message: `is declared by ${source} but the inventory did not return it — the inventory is incomplete, so every other conclusion here is unsound. Fix the inventory adapter (or add the name to detection.ignore if it is not a real entity).`,
|
|
343
|
+
severity: "error",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const name of REQUIRED_ASSURANCES) {
|
|
348
|
+
const assurance = contract.assurances?.[name];
|
|
349
|
+
if (!assurance?.evidence) {
|
|
350
|
+
findings.push({
|
|
351
|
+
code: "assurance-unevidenced",
|
|
352
|
+
subject: name,
|
|
353
|
+
message:
|
|
354
|
+
"is not declared with an evidence pointer — a required property of the reset with no test, guard, role or grant behind it is a claim, not an assurance.",
|
|
355
|
+
severity: "error",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const counts = {
|
|
361
|
+
"fixture-owned": 0,
|
|
362
|
+
preserve: 0,
|
|
363
|
+
"derived-rebuild": 0,
|
|
364
|
+
forbidden: 0,
|
|
365
|
+
};
|
|
366
|
+
for (const entity of entities) {
|
|
367
|
+
counts[entity.policy] = (counts[entity.policy] ?? 0) + 1;
|
|
368
|
+
}
|
|
369
|
+
const errors = findings.filter(finding => finding.severity === "error");
|
|
370
|
+
return {
|
|
371
|
+
status: errors.length === 0 ? "completed" : "failed",
|
|
372
|
+
findings,
|
|
373
|
+
summary: {
|
|
374
|
+
deleted: 0,
|
|
375
|
+
created: 0,
|
|
376
|
+
preserved: counts.preserve + counts.forbidden,
|
|
377
|
+
classified: entities.length,
|
|
378
|
+
inventoried: inventoryIds.size,
|
|
379
|
+
unclassified: findings.filter(f => f.code === "unclassified-entity")
|
|
380
|
+
.length,
|
|
381
|
+
waived: waived.size,
|
|
382
|
+
policies: counts,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Verify a `declared-noop` against the repository instead of trusting it.
|
|
389
|
+
* @param {object} contract - Parsed contract with mode declared-noop
|
|
390
|
+
* @param {string} root - Project root
|
|
391
|
+
* @returns {{ status: string, findings: object[] }} Verdict
|
|
392
|
+
*/
|
|
393
|
+
export function verifyDeclaredNoop(contract, root) {
|
|
394
|
+
const findings = [];
|
|
395
|
+
if (
|
|
396
|
+
!contract.noop?.reason ||
|
|
397
|
+
!contract.noop?.owner ||
|
|
398
|
+
!contract.noop?.capabilityManifest
|
|
399
|
+
) {
|
|
400
|
+
findings.push({
|
|
401
|
+
code: "noop-underspecified",
|
|
402
|
+
subject: "noop",
|
|
403
|
+
message:
|
|
404
|
+
"a declared noop needs a reason, an owner, and a capability-manifest reference — a bare success is indistinguishable from a successful destructive run.",
|
|
405
|
+
severity: "error",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
const signals = detectPersistenceSignals(root);
|
|
409
|
+
if (signals.length > 0) {
|
|
410
|
+
findings.push({
|
|
411
|
+
code: "noop-contradicted",
|
|
412
|
+
subject: signals[0].file,
|
|
413
|
+
message: `this repository shows ${signals.length} persistence signal(s) (first: ${signals[0].kind} at ${signals[0].file}) — a repo that persists state may not declare a reset noop.`,
|
|
414
|
+
severity: "error",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
status: findings.length === 0 ? "no-op" : "invalid",
|
|
419
|
+
findings,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Parse CLI arguments.
|
|
425
|
+
* @param {string[]} argv - Arguments after the script name
|
|
426
|
+
* @returns {{ contract?: string, inventory?: string, root?: string, correlationId?: string }} Options
|
|
427
|
+
*/
|
|
428
|
+
export function parseArgs(argv) {
|
|
429
|
+
const options = {};
|
|
430
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
431
|
+
const flag = argv[index];
|
|
432
|
+
if (flag === "--contract") options.contract = argv[++index];
|
|
433
|
+
else if (flag === "--inventory") options.inventory = argv[++index];
|
|
434
|
+
else if (flag === "--root") options.root = argv[++index];
|
|
435
|
+
else if (flag === "--correlation-id") options.correlationId = argv[++index];
|
|
436
|
+
}
|
|
437
|
+
return options;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Run the check and produce the standard command envelope.
|
|
442
|
+
* @param {object} [options] - Overrides for paths and correlation id
|
|
443
|
+
* @returns {{ envelope: object, findings: object[] }} Result
|
|
444
|
+
*/
|
|
445
|
+
export function run(options = {}) {
|
|
446
|
+
const root = path.resolve(
|
|
447
|
+
options.root ?? process.env.STATE_ROOT ?? process.cwd()
|
|
448
|
+
);
|
|
449
|
+
const contractPath = path.resolve(
|
|
450
|
+
root,
|
|
451
|
+
options.contract ?? process.env.STATE_CONTRACT_FILE ?? DEFAULT_CONTRACT_PATH
|
|
452
|
+
);
|
|
453
|
+
const correlationId =
|
|
454
|
+
options.correlationId ??
|
|
455
|
+
process.env.STATE_CORRELATION_ID ??
|
|
456
|
+
`state-classification-${Date.now()}`;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Assemble the envelope for one outcome.
|
|
460
|
+
* @param {string} status - Envelope status
|
|
461
|
+
* @param {object} [extra] - Additional envelope fields
|
|
462
|
+
* @returns {object} Built envelope
|
|
463
|
+
*/
|
|
464
|
+
const envelopeFor = (status, extra = {}) =>
|
|
465
|
+
buildEnvelope({
|
|
466
|
+
capability: "state-classification",
|
|
467
|
+
mode: extra.mode ?? "real",
|
|
468
|
+
operation: "compare-inventory-to-contract",
|
|
469
|
+
environment: process.env.STATE_ENVIRONMENT ?? "repository",
|
|
470
|
+
contractVersion: extra.contractVersion ?? "unknown",
|
|
471
|
+
dryRun: true,
|
|
472
|
+
status,
|
|
473
|
+
correlationId,
|
|
474
|
+
...extra,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const contractRead = readJsonFile(contractPath);
|
|
478
|
+
if (!contractRead.ok && contractRead.missing) {
|
|
479
|
+
return {
|
|
480
|
+
findings: [],
|
|
481
|
+
envelope: envelopeFor("not-adopted", {
|
|
482
|
+
reason: `no state contract at ${path.relative(root, contractPath)} — adoption is an explicit act, so this repo is not silently passing a gate it never wired.`,
|
|
483
|
+
}),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (!contractRead.ok) {
|
|
487
|
+
return {
|
|
488
|
+
findings: [],
|
|
489
|
+
envelope: envelopeFor("invalid", { reason: contractRead.error }),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const contract = contractRead.value;
|
|
494
|
+
const schemaCheck = validateAgainstSchema(contract, STATE_CONTRACT_SCHEMA);
|
|
495
|
+
if (!schemaCheck.valid) {
|
|
496
|
+
return {
|
|
497
|
+
findings: schemaCheck.errors.map(message => ({
|
|
498
|
+
code: "contract-invalid",
|
|
499
|
+
subject: "state-contract",
|
|
500
|
+
message,
|
|
501
|
+
severity: "error",
|
|
502
|
+
})),
|
|
503
|
+
envelope: envelopeFor("invalid", {
|
|
504
|
+
contractVersion: contract.contractVersion,
|
|
505
|
+
reason:
|
|
506
|
+
"the state contract does not validate against its schema — a broken contract is never read as an empty one.",
|
|
507
|
+
}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (contract.mode === "declared-noop") {
|
|
512
|
+
const verdict = verifyDeclaredNoop(contract, root);
|
|
513
|
+
return {
|
|
514
|
+
findings: verdict.findings,
|
|
515
|
+
envelope: envelopeFor(verdict.status, {
|
|
516
|
+
contractVersion: contract.contractVersion,
|
|
517
|
+
...(verdict.status === "no-op"
|
|
518
|
+
? {
|
|
519
|
+
mode: "declared-noop",
|
|
520
|
+
reason: contract.noop.reason,
|
|
521
|
+
owner: contract.noop.owner,
|
|
522
|
+
capabilityManifest: contract.noop.capabilityManifest,
|
|
523
|
+
}
|
|
524
|
+
: {
|
|
525
|
+
reason:
|
|
526
|
+
"the declared noop is contradicted by the repository or is underspecified.",
|
|
527
|
+
}),
|
|
528
|
+
findings: verdict.findings,
|
|
529
|
+
}),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const inventoryPath = path.resolve(
|
|
534
|
+
root,
|
|
535
|
+
options.inventory ??
|
|
536
|
+
process.env.STATE_INVENTORY_FILE ??
|
|
537
|
+
DEFAULT_INVENTORY_PATH
|
|
538
|
+
);
|
|
539
|
+
const inventoryRead = readJsonFile(inventoryPath);
|
|
540
|
+
if (!inventoryRead.ok) {
|
|
541
|
+
const declared = detectDeclaredEntities(
|
|
542
|
+
root,
|
|
543
|
+
contract.detection?.sources ?? []
|
|
544
|
+
);
|
|
545
|
+
return {
|
|
546
|
+
findings: [...declared].map(([name, source]) => ({
|
|
547
|
+
code: "detected-entity",
|
|
548
|
+
subject: name,
|
|
549
|
+
message: `declared by ${source}; non-authoritative until a runtime inventory confirms it`,
|
|
550
|
+
severity: "warning",
|
|
551
|
+
})),
|
|
552
|
+
envelope: envelopeFor("detection-only", {
|
|
553
|
+
contractVersion: contract.contractVersion,
|
|
554
|
+
reason: `no runtime inventory at ${path.relative(root, inventoryPath)} — detectors ran, but findings are non-authoritative and exit 0 must never mean "I could not look". Produce one with the project's \`state:inventory\` adapter.`,
|
|
555
|
+
}),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const verdict = compareInventory({
|
|
560
|
+
contract,
|
|
561
|
+
inventory: inventoryRead.value,
|
|
562
|
+
declared: detectDeclaredEntities(root, contract.detection?.sources ?? []),
|
|
563
|
+
});
|
|
564
|
+
return {
|
|
565
|
+
findings: verdict.findings,
|
|
566
|
+
envelope: envelopeFor(verdict.status, {
|
|
567
|
+
contractVersion: contract.contractVersion,
|
|
568
|
+
summary: verdict.summary,
|
|
569
|
+
findings: verdict.findings,
|
|
570
|
+
...(verdict.status === "completed"
|
|
571
|
+
? {}
|
|
572
|
+
: {
|
|
573
|
+
reason: `${verdict.findings.filter(f => f.severity === "error").length} classification finding(s) — see findings.`,
|
|
574
|
+
}),
|
|
575
|
+
}),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* CLI entry point.
|
|
581
|
+
* @param {string[]} argv - Arguments after the script name
|
|
582
|
+
* @returns {number} Process exit code
|
|
583
|
+
*/
|
|
584
|
+
export function main(argv) {
|
|
585
|
+
const { envelope, findings } = run(parseArgs(argv));
|
|
586
|
+
for (const finding of findings) {
|
|
587
|
+
const stream =
|
|
588
|
+
finding.severity === "error" ? process.stderr : process.stdout;
|
|
589
|
+
stream.write(
|
|
590
|
+
redact(
|
|
591
|
+
`[state-classification] ${finding.severity.toUpperCase()} ${finding.code}: ${finding.subject} ${finding.message}\n`
|
|
592
|
+
)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (envelope.reason) {
|
|
596
|
+
process.stderr.write(
|
|
597
|
+
redact(`[state-classification] ${envelope.status}: ${envelope.reason}\n`)
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return emitEnvelope(envelope);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Run only when invoked directly — importing for tests must have no side effects.
|
|
604
|
+
if (
|
|
605
|
+
process.argv[1] &&
|
|
606
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
607
|
+
) {
|
|
608
|
+
process.exitCode = main(process.argv.slice(2));
|
|
609
|
+
}
|