@codyswann/lisa 2.345.1 → 2.348.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.
Files changed (139) hide show
  1. package/all/copy-overwrite/scripts/check-state-classification.mjs +609 -0
  2. package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +194 -0
  3. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +208 -0
  4. package/all/copy-overwrite/scripts/schemas/lisa-command-envelope.v1.schema.json +119 -0
  5. package/all/copy-overwrite/scripts/schemas/lisa-state-contract.v1.schema.json +191 -0
  6. package/all/create-only/state/README.md +41 -0
  7. package/all/create-only/state/state-contract.example.json +46 -0
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +92 -10
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +224 -0
  12. package/expo/copy-overwrite/scripts/bdd/contract.mjs +121 -0
  13. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +231 -0
  14. package/expo/copy-overwrite/scripts/bdd/parse.mjs +220 -0
  15. package/expo/copy-overwrite/scripts/bdd/render.mjs +170 -0
  16. package/expo/copy-overwrite/scripts/bdd/report.mjs +361 -0
  17. package/expo/copy-overwrite/scripts/bdd/validate.mjs +282 -0
  18. package/expo/copy-overwrite/scripts/bdd/waivers.mjs +210 -0
  19. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +278 -0
  20. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +657 -0
  21. package/expo/create-only/bdd/coverage-map.json +38 -0
  22. package/expo/create-only/bdd/features/.keep +5 -0
  23. package/expo/github-rulesets/bdd-coverage.json +33 -0
  24. package/expo/package-lisa/package.lisa.json +3 -0
  25. package/package.json +1 -1
  26. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa/.codex-plugin/skills/lisa-acceptance-criteria/SKILL.md +1 -0
  29. package/plugins/lisa/.codex-plugin/skills/lisa-codify-verification/SKILL.md +4 -0
  30. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
  31. package/plugins/lisa/.codex-plugin/skills/lisa-research/SKILL.md +7 -1
  32. package/plugins/lisa/.codex-plugin/skills/lisa-task-decomposition/SKILL.md +2 -0
  33. package/plugins/lisa/.codex-plugin/skills/lisa-test-strategy/SKILL.md +1 -0
  34. package/plugins/lisa/.codex-plugin/skills/lisa-verification-lifecycle/SKILL.md +1 -0
  35. package/plugins/lisa/.codex-plugin/skills/lisa-verify/SKILL.md +1 -1
  36. package/plugins/lisa/rules/eager/reset-seed-coverage.md +48 -0
  37. package/plugins/lisa/rules/reference/reset-seed-coverage.md +231 -0
  38. package/plugins/lisa/skills/lisa-acceptance-criteria/SKILL.md +1 -0
  39. package/plugins/lisa/skills/lisa-codify-verification/SKILL.md +4 -0
  40. package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
  41. package/plugins/lisa/skills/lisa-research/SKILL.md +7 -1
  42. package/plugins/lisa/skills/lisa-task-decomposition/SKILL.md +2 -0
  43. package/plugins/lisa/skills/lisa-test-strategy/SKILL.md +1 -0
  44. package/plugins/lisa/skills/lisa-verification-lifecycle/SKILL.md +1 -0
  45. package/plugins/lisa/skills/lisa-verify/SKILL.md +1 -1
  46. package/plugins/lisa-agy/plugin.json +1 -1
  47. package/plugins/lisa-agy/skills/lisa-acceptance-criteria/SKILL.md +1 -0
  48. package/plugins/lisa-agy/skills/lisa-codify-verification/SKILL.md +4 -0
  49. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
  50. package/plugins/lisa-agy/skills/lisa-research/SKILL.md +7 -1
  51. package/plugins/lisa-agy/skills/lisa-task-decomposition/SKILL.md +2 -0
  52. package/plugins/lisa-agy/skills/lisa-test-strategy/SKILL.md +1 -0
  53. package/plugins/lisa-agy/skills/lisa-verification-lifecycle/SKILL.md +1 -0
  54. package/plugins/lisa-agy/skills/lisa-verify/SKILL.md +1 -1
  55. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  58. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-copilot/rules/eager/reset-seed-coverage.md +48 -0
  62. package/plugins/lisa-copilot/rules/reference/reset-seed-coverage.md +231 -0
  63. package/plugins/lisa-copilot/skills/lisa-acceptance-criteria/SKILL.md +1 -0
  64. package/plugins/lisa-copilot/skills/lisa-codify-verification/SKILL.md +4 -0
  65. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
  66. package/plugins/lisa-copilot/skills/lisa-research/SKILL.md +7 -1
  67. package/plugins/lisa-copilot/skills/lisa-task-decomposition/SKILL.md +2 -0
  68. package/plugins/lisa-copilot/skills/lisa-test-strategy/SKILL.md +1 -0
  69. package/plugins/lisa-copilot/skills/lisa-verification-lifecycle/SKILL.md +1 -0
  70. package/plugins/lisa-copilot/skills/lisa-verify/SKILL.md +1 -1
  71. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-cursor/rules/reset-seed-coverage-reference.mdc +236 -0
  73. package/plugins/lisa-cursor/rules/reset-seed-coverage.mdc +53 -0
  74. package/plugins/lisa-cursor/skills/lisa-acceptance-criteria/SKILL.md +1 -0
  75. package/plugins/lisa-cursor/skills/lisa-codify-verification/SKILL.md +4 -0
  76. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
  77. package/plugins/lisa-cursor/skills/lisa-research/SKILL.md +7 -1
  78. package/plugins/lisa-cursor/skills/lisa-task-decomposition/SKILL.md +2 -0
  79. package/plugins/lisa-cursor/skills/lisa-test-strategy/SKILL.md +1 -0
  80. package/plugins/lisa-cursor/skills/lisa-verification-lifecycle/SKILL.md +1 -0
  81. package/plugins/lisa-cursor/skills/lisa-verify/SKILL.md +1 -1
  82. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo/.codex-plugin/skills/ops-db-ops/SKILL.md +93 -2
  85. package/plugins/lisa-expo/skills/ops-db-ops/SKILL.md +94 -3
  86. package/plugins/lisa-expo/skills/ops-db-ops/agents/openai.yaml +2 -2
  87. package/plugins/lisa-expo-agy/plugin.json +1 -1
  88. package/plugins/lisa-expo-agy/skills/ops-db-ops/SKILL.md +94 -3
  89. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-expo-copilot/skills/ops-db-ops/SKILL.md +94 -3
  91. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-expo-cursor/skills/ops-db-ops/SKILL.md +94 -3
  93. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  96. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  101. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  106. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  111. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  115. package/plugins/lisa-rails-agy/plugin.json +1 -1
  116. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  120. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  121. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  124. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  125. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  126. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  127. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  128. package/plugins/src/base/rules/eager/reset-seed-coverage.md +48 -0
  129. package/plugins/src/base/rules/reference/reset-seed-coverage.md +231 -0
  130. package/plugins/src/base/skills/lisa-acceptance-criteria/SKILL.md +1 -0
  131. package/plugins/src/base/skills/lisa-codify-verification/SKILL.md +4 -0
  132. package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
  133. package/plugins/src/base/skills/lisa-research/SKILL.md +7 -1
  134. package/plugins/src/base/skills/lisa-task-decomposition/SKILL.md +2 -0
  135. package/plugins/src/base/skills/lisa-test-strategy/SKILL.md +1 -0
  136. package/plugins/src/base/skills/lisa-verification-lifecycle/SKILL.md +1 -0
  137. package/plugins/src/base/skills/lisa-verify/SKILL.md +1 -1
  138. package/plugins/src/expo/skills/ops-db-ops/SKILL.md +94 -3
  139. 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
+ }