@nforma.ai/nforma 0.2.1

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 (215) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +1024 -0
  3. package/agents/qgsd-codebase-mapper.md +764 -0
  4. package/agents/qgsd-debugger.md +1201 -0
  5. package/agents/qgsd-executor.md +472 -0
  6. package/agents/qgsd-integration-checker.md +443 -0
  7. package/agents/qgsd-phase-researcher.md +502 -0
  8. package/agents/qgsd-plan-checker.md +643 -0
  9. package/agents/qgsd-planner.md +1182 -0
  10. package/agents/qgsd-project-researcher.md +621 -0
  11. package/agents/qgsd-quorum-orchestrator.md +628 -0
  12. package/agents/qgsd-quorum-slot-worker.md +41 -0
  13. package/agents/qgsd-quorum-synthesizer.md +133 -0
  14. package/agents/qgsd-quorum-test-worker.md +37 -0
  15. package/agents/qgsd-quorum-worker.md +161 -0
  16. package/agents/qgsd-research-synthesizer.md +239 -0
  17. package/agents/qgsd-roadmapper.md +660 -0
  18. package/agents/qgsd-verifier.md +628 -0
  19. package/bin/accept-debug-invariant.cjs +165 -0
  20. package/bin/account-manager.cjs +719 -0
  21. package/bin/aggregate-requirements.cjs +466 -0
  22. package/bin/analyze-assumptions.cjs +757 -0
  23. package/bin/analyze-state-space.cjs +921 -0
  24. package/bin/attribute-trace-divergence.cjs +150 -0
  25. package/bin/auth-drivers/gh-cli.cjs +93 -0
  26. package/bin/auth-drivers/index.cjs +46 -0
  27. package/bin/auth-drivers/pool.cjs +67 -0
  28. package/bin/auth-drivers/simple.cjs +95 -0
  29. package/bin/autoClosePtoF.cjs +110 -0
  30. package/bin/blessed-terminal.cjs +350 -0
  31. package/bin/build-phase-index.cjs +472 -0
  32. package/bin/call-quorum-slot.cjs +541 -0
  33. package/bin/ccr-secure-config.cjs +99 -0
  34. package/bin/ccr-secure-start.cjs +83 -0
  35. package/bin/check-bundled-sdks.cjs +177 -0
  36. package/bin/check-coverage-guard.cjs +112 -0
  37. package/bin/check-liveness-fairness.cjs +95 -0
  38. package/bin/check-mcp-health.cjs +123 -0
  39. package/bin/check-provider-health.cjs +395 -0
  40. package/bin/check-results-exit.cjs +24 -0
  41. package/bin/check-spec-sync.cjs +360 -0
  42. package/bin/check-trace-redaction.cjs +271 -0
  43. package/bin/check-trace-schema-drift.cjs +99 -0
  44. package/bin/compareDrift.cjs +21 -0
  45. package/bin/conformance-schema.cjs +12 -0
  46. package/bin/count-scenarios.cjs +420 -0
  47. package/bin/debt-dedup.cjs +144 -0
  48. package/bin/debt-ledger.cjs +61 -0
  49. package/bin/debt-retention.cjs +76 -0
  50. package/bin/debt-state-machine.cjs +80 -0
  51. package/bin/detect-coverage-gaps.cjs +204 -0
  52. package/bin/detect-project-intent.cjs +362 -0
  53. package/bin/export-prism-constants.cjs +164 -0
  54. package/bin/extract-annotations.cjs +633 -0
  55. package/bin/extractFormalExpected.cjs +104 -0
  56. package/bin/fingerprint-drift.cjs +24 -0
  57. package/bin/fingerprint-issue.cjs +46 -0
  58. package/bin/formal-core.cjs +519 -0
  59. package/bin/formal-ref-linker.cjs +141 -0
  60. package/bin/formal-test-sync.cjs +788 -0
  61. package/bin/generate-formal-specs.cjs +588 -0
  62. package/bin/generate-petri-net.cjs +397 -0
  63. package/bin/generate-phase-spec.cjs +249 -0
  64. package/bin/generate-proposed-changes.cjs +194 -0
  65. package/bin/generate-tla-cfg.cjs +122 -0
  66. package/bin/generate-traceability-matrix.cjs +701 -0
  67. package/bin/generate-triage-bundle.cjs +300 -0
  68. package/bin/gh-account-rotate.cjs +34 -0
  69. package/bin/initialize-model-registry.cjs +105 -0
  70. package/bin/install-formal-tools.cjs +382 -0
  71. package/bin/install.js +2424 -0
  72. package/bin/isNumericThreshold.cjs +34 -0
  73. package/bin/issue-classifier.cjs +151 -0
  74. package/bin/levenshtein.cjs +74 -0
  75. package/bin/lint-formal-models.cjs +580 -0
  76. package/bin/load-baseline-requirements.cjs +275 -0
  77. package/bin/manage-agents-core.cjs +815 -0
  78. package/bin/migrate-formal-dir.cjs +172 -0
  79. package/bin/migrate-planning.cjs +206 -0
  80. package/bin/migrate-to-slots.cjs +255 -0
  81. package/bin/nForma.cjs +2726 -0
  82. package/bin/observe-config.cjs +353 -0
  83. package/bin/observe-debt-writer.cjs +140 -0
  84. package/bin/observe-handler-grafana.cjs +128 -0
  85. package/bin/observe-handler-internal.cjs +301 -0
  86. package/bin/observe-handler-logstash.cjs +153 -0
  87. package/bin/observe-handler-prometheus.cjs +185 -0
  88. package/bin/observe-handlers.cjs +436 -0
  89. package/bin/observe-registry.cjs +131 -0
  90. package/bin/observe-render.cjs +168 -0
  91. package/bin/planning-paths.cjs +167 -0
  92. package/bin/polyrepo.cjs +560 -0
  93. package/bin/prism-priority.cjs +153 -0
  94. package/bin/probe-quorum-slots.cjs +167 -0
  95. package/bin/promote-model.cjs +225 -0
  96. package/bin/propose-debug-invariants.cjs +165 -0
  97. package/bin/providers.json +392 -0
  98. package/bin/pty-proxy.py +129 -0
  99. package/bin/qgsd-solve.cjs +2477 -0
  100. package/bin/quorum-consensus-gate.cjs +238 -0
  101. package/bin/quorum-formal-context.cjs +183 -0
  102. package/bin/quorum-slot-dispatch.cjs +934 -0
  103. package/bin/read-policy.cjs +60 -0
  104. package/bin/requirement-map.cjs +63 -0
  105. package/bin/requirements-core.cjs +247 -0
  106. package/bin/resolve-cli.cjs +101 -0
  107. package/bin/review-mcp-logs.cjs +294 -0
  108. package/bin/run-account-manager-tlc.cjs +188 -0
  109. package/bin/run-account-pool-alloy.cjs +158 -0
  110. package/bin/run-alloy.cjs +153 -0
  111. package/bin/run-audit-alloy.cjs +187 -0
  112. package/bin/run-breaker-tlc.cjs +181 -0
  113. package/bin/run-formal-check.cjs +395 -0
  114. package/bin/run-formal-verify.cjs +701 -0
  115. package/bin/run-installer-alloy.cjs +188 -0
  116. package/bin/run-oauth-rotation-prism.cjs +132 -0
  117. package/bin/run-oscillation-tlc.cjs +202 -0
  118. package/bin/run-phase-tlc.cjs +228 -0
  119. package/bin/run-prism.cjs +446 -0
  120. package/bin/run-protocol-tlc.cjs +201 -0
  121. package/bin/run-quorum-composition-alloy.cjs +155 -0
  122. package/bin/run-sensitivity-sweep.cjs +231 -0
  123. package/bin/run-stop-hook-tlc.cjs +188 -0
  124. package/bin/run-tlc.cjs +467 -0
  125. package/bin/run-transcript-alloy.cjs +173 -0
  126. package/bin/run-uppaal.cjs +264 -0
  127. package/bin/secrets.cjs +134 -0
  128. package/bin/sensitivity-report.cjs +219 -0
  129. package/bin/sensitivity-sweep-feedback.cjs +194 -0
  130. package/bin/set-secret.cjs +29 -0
  131. package/bin/setup-telemetry-cron.sh +36 -0
  132. package/bin/sweepPtoF.cjs +63 -0
  133. package/bin/sync-baseline-requirements.cjs +290 -0
  134. package/bin/task-envelope.cjs +360 -0
  135. package/bin/telemetry-collector.cjs +229 -0
  136. package/bin/unified-mcp-server.mjs +735 -0
  137. package/bin/update-agents.cjs +369 -0
  138. package/bin/update-scoreboard.cjs +1134 -0
  139. package/bin/validate-debt-entry.cjs +207 -0
  140. package/bin/validate-invariant.cjs +419 -0
  141. package/bin/validate-memory.cjs +389 -0
  142. package/bin/validate-requirements-haiku.cjs +435 -0
  143. package/bin/validate-traces.cjs +438 -0
  144. package/bin/verify-formal-results.cjs +124 -0
  145. package/bin/verify-quorum-health.cjs +273 -0
  146. package/bin/write-check-result.cjs +106 -0
  147. package/bin/xstate-to-tla.cjs +483 -0
  148. package/bin/xstate-trace-walker.cjs +205 -0
  149. package/commands/qgsd/add-phase.md +43 -0
  150. package/commands/qgsd/add-requirement.md +24 -0
  151. package/commands/qgsd/add-todo.md +47 -0
  152. package/commands/qgsd/audit-milestone.md +37 -0
  153. package/commands/qgsd/check-todos.md +45 -0
  154. package/commands/qgsd/cleanup.md +18 -0
  155. package/commands/qgsd/close-formal-gaps.md +33 -0
  156. package/commands/qgsd/complete-milestone.md +136 -0
  157. package/commands/qgsd/debug.md +166 -0
  158. package/commands/qgsd/discuss-phase.md +83 -0
  159. package/commands/qgsd/execute-phase.md +117 -0
  160. package/commands/qgsd/fix-tests.md +27 -0
  161. package/commands/qgsd/formal-test-sync.md +32 -0
  162. package/commands/qgsd/health.md +22 -0
  163. package/commands/qgsd/help.md +22 -0
  164. package/commands/qgsd/insert-phase.md +32 -0
  165. package/commands/qgsd/join-discord.md +18 -0
  166. package/commands/qgsd/list-phase-assumptions.md +46 -0
  167. package/commands/qgsd/map-codebase.md +71 -0
  168. package/commands/qgsd/map-requirements.md +20 -0
  169. package/commands/qgsd/mcp-restart.md +176 -0
  170. package/commands/qgsd/mcp-set-model.md +134 -0
  171. package/commands/qgsd/mcp-setup.md +1371 -0
  172. package/commands/qgsd/mcp-status.md +274 -0
  173. package/commands/qgsd/mcp-update.md +238 -0
  174. package/commands/qgsd/new-milestone.md +44 -0
  175. package/commands/qgsd/new-project.md +42 -0
  176. package/commands/qgsd/observe.md +260 -0
  177. package/commands/qgsd/pause-work.md +38 -0
  178. package/commands/qgsd/plan-milestone-gaps.md +34 -0
  179. package/commands/qgsd/plan-phase.md +44 -0
  180. package/commands/qgsd/polyrepo.md +50 -0
  181. package/commands/qgsd/progress.md +24 -0
  182. package/commands/qgsd/queue.md +54 -0
  183. package/commands/qgsd/quick.md +133 -0
  184. package/commands/qgsd/quorum-test.md +275 -0
  185. package/commands/qgsd/quorum.md +707 -0
  186. package/commands/qgsd/reapply-patches.md +110 -0
  187. package/commands/qgsd/remove-phase.md +31 -0
  188. package/commands/qgsd/research-phase.md +189 -0
  189. package/commands/qgsd/resume-work.md +40 -0
  190. package/commands/qgsd/set-profile.md +34 -0
  191. package/commands/qgsd/settings.md +39 -0
  192. package/commands/qgsd/solve.md +565 -0
  193. package/commands/qgsd/sync-baselines.md +119 -0
  194. package/commands/qgsd/triage.md +233 -0
  195. package/commands/qgsd/update.md +37 -0
  196. package/commands/qgsd/verify-work.md +38 -0
  197. package/hooks/dist/config-loader.js +297 -0
  198. package/hooks/dist/conformance-schema.cjs +12 -0
  199. package/hooks/dist/gsd-context-monitor.js +64 -0
  200. package/hooks/dist/qgsd-check-update.js +62 -0
  201. package/hooks/dist/qgsd-circuit-breaker.js +682 -0
  202. package/hooks/dist/qgsd-precompact.js +156 -0
  203. package/hooks/dist/qgsd-prompt.js +653 -0
  204. package/hooks/dist/qgsd-session-start.js +122 -0
  205. package/hooks/dist/qgsd-slot-correlator.js +58 -0
  206. package/hooks/dist/qgsd-spec-regen.js +86 -0
  207. package/hooks/dist/qgsd-statusline.js +91 -0
  208. package/hooks/dist/qgsd-stop.js +553 -0
  209. package/hooks/dist/qgsd-token-collector.js +133 -0
  210. package/hooks/dist/unified-mcp-server.mjs +669 -0
  211. package/package.json +95 -0
  212. package/scripts/build-hooks.js +46 -0
  213. package/scripts/postinstall.js +48 -0
  214. package/scripts/secret-audit.sh +45 -0
  215. package/templates/qgsd.json +49 -0
@@ -0,0 +1,921 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/analyze-state-space.cjs
4
+ // Static analyzer for TLA+ models — estimates state-space size per model,
5
+ // classifies risk (MINIMAL/LOW/MODERATE/HIGH), and flags unbounded domains.
6
+ //
7
+ // Data sources:
8
+ // 1. .planning/formal/model-registry.json (model file inventory)
9
+ // 2. .planning/formal/tla/*.cfg (TLC model-checking constants)
10
+ // 3. .planning/formal/tla/*.tla (VARIABLES + TypeOK domain declarations)
11
+ //
12
+ // Usage:
13
+ // node bin/analyze-state-space.cjs # write to .planning/formal/state-space-report.json + summary
14
+ // node bin/analyze-state-space.cjs --json # print JSON to stdout
15
+ // node bin/analyze-state-space.cjs --quiet # suppress summary output
16
+ //
17
+ // Requirements: DECOMP-01, DECOMP-02
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const TAG = '[analyze-state-space]';
23
+ let ROOT = process.cwd();
24
+
25
+ // Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
26
+ for (const arg of process.argv.slice(2)) {
27
+ if (arg.startsWith('--project-root=')) {
28
+ ROOT = path.resolve(arg.slice('--project-root='.length));
29
+ }
30
+ }
31
+
32
+ const REGISTRY_PATH = path.join(ROOT, '.planning', 'formal', 'model-registry.json');
33
+ const TLA_DIR = path.join(ROOT, '.planning', 'formal', 'tla');
34
+ const OUTPUT_PATH = path.join(ROOT, '.planning', 'formal', 'state-space-report.json');
35
+
36
+ // ── CLI flags ───────────────────────────────────────────────────────────────
37
+
38
+ const args = process.argv.slice(2);
39
+ const jsonMode = args.includes('--json');
40
+ const quietMode = args.includes('--quiet');
41
+
42
+ // ── Risk thresholds (configurable) ──────────────────────────────────────────
43
+
44
+ const DEFAULT_THRESHOLDS = {
45
+ MINIMAL: 1000, // <= 1,000 states
46
+ LOW: 100000, // <= 100,000 states
47
+ MODERATE: 10000000, // <= 10,000,000 states
48
+ // > 10,000,000 or unbounded = HIGH
49
+ };
50
+
51
+ // ── Cross-model merge budget (configurable) ─────────────────────────────────
52
+ // 5 minutes at ~10,000 states/second = 3,000,000 states threshold
53
+
54
+ const MERGE_BUDGET = {
55
+ max_tlc_seconds: 300,
56
+ throughput_states_per_sec: 10000,
57
+ max_merged_states: 300 * 10000, // 3,000,000
58
+ };
59
+
60
+ // ── CFG → TLA module mapping ────────────────────────────────────────────────
61
+ // Reverse map: cfg base name (without .cfg) → TLA module name
62
+ // Needed because cfg naming is inconsistent (MCbreaker vs MCQGSDQuorum etc.)
63
+
64
+ const CFG_TO_MODULE = {
65
+ 'MCbreaker': 'QGSDCircuitBreaker',
66
+ 'MCoscillation': 'QGSDOscillation',
67
+ 'MCconvergence': 'QGSDConvergence',
68
+ 'MCdeliberation': 'QGSDDeliberation',
69
+ 'MCprefilter': 'QGSDPreFilter',
70
+ 'MCsafety': 'QGSDQuorum',
71
+ 'MCliveness': 'QGSDQuorum',
72
+ 'MCQGSDQuorum': 'QGSDQuorum_xstate',
73
+ 'MCaccount-manager': 'QGSDAccountManager',
74
+ 'MCMCPEnv': 'QGSDMCPEnv',
75
+ 'MCTUINavigation': 'TUINavigation',
76
+ 'MCStopHook': 'QGSDStopHook',
77
+ 'MCrecruiting-safety': 'QGSDRecruiting',
78
+ 'MCrecruiting-liveness': 'QGSDRecruiting',
79
+ };
80
+
81
+ // ── Parsing Utilities ───────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Parse a TLA+ .cfg file. Extracts CONSTANTS, INVARIANT count, PROPERTY count.
85
+ * @param {string} cfgPath — absolute path to .cfg file
86
+ * @returns {{ constants: Array, invariant_count: number, property_count: number }}
87
+ */
88
+ function parseCfg(cfgPath) {
89
+ const constants = [];
90
+ let invariantCount = 0;
91
+ let propertyCount = 0;
92
+
93
+ let content;
94
+ try {
95
+ content = fs.readFileSync(cfgPath, 'utf8');
96
+ } catch (err) {
97
+ process.stderr.write(TAG + ' warn: cannot read ' + cfgPath + ': ' + err.message + '\n');
98
+ return { constants, invariant_count: invariantCount, property_count: propertyCount };
99
+ }
100
+
101
+ const lines = content.split('\n');
102
+ let inConstants = false;
103
+
104
+ for (const rawLine of lines) {
105
+ const line = rawLine.replace(/\\?\*.*$/, '').trim(); // strip TLA+ comments
106
+ if (!line) continue;
107
+
108
+ // Detect section keywords
109
+ if (/^CONSTANTS?\b/i.test(line)) {
110
+ inConstants = true;
111
+ // Check for inline constant on same line: CONSTANTS\n or CONSTANTS Foo = 1
112
+ const inlinePart = line.replace(/^CONSTANTS?\s*/i, '').trim();
113
+ if (inlinePart) {
114
+ parseConstantLine(inlinePart, constants);
115
+ }
116
+ continue;
117
+ }
118
+
119
+ // End of CONSTANTS block when we hit another section keyword
120
+ if (/^(SPECIFICATION|INVARIANTS?|PROPERT(Y|IES)|CHECK_DEADLOCK|SYMMETRY|CONSTRAINT)\b/i.test(line)) {
121
+ inConstants = false;
122
+ }
123
+
124
+ if (inConstants) {
125
+ parseConstantLine(line, constants);
126
+ continue;
127
+ }
128
+
129
+ if (/^INVARIANTS?\b/i.test(line)) {
130
+ // Inline invariant on same line
131
+ const inlinePart = line.replace(/^INVARIANTS?\s*/i, '').trim();
132
+ if (inlinePart) invariantCount++;
133
+ continue;
134
+ }
135
+ if (/^PROPERT(Y|IES)\b/i.test(line)) {
136
+ const inlinePart = line.replace(/^PROPERT(Y|IES)\s*/i, '').trim();
137
+ if (inlinePart) propertyCount++;
138
+ continue;
139
+ }
140
+
141
+ // Lines after INVARIANT/PROPERTY keywords (multi-line format)
142
+ // Actually, TLC cfg uses one-per-line for INVARIANT entries
143
+ // We count each non-keyword, non-blank line in the right context
144
+ // For simplicity, let's just count the keywords themselves
145
+ }
146
+
147
+ // Recount invariants and properties more carefully — each INVARIANT/PROPERTY line
148
+ const allLines = content.split('\n');
149
+ invariantCount = 0;
150
+ propertyCount = 0;
151
+ for (const rawLine of allLines) {
152
+ const trimmed = rawLine.replace(/\\?\*.*$/, '').trim();
153
+ if (/^INVARIANTS?\s+\S/i.test(trimmed)) {
154
+ // Count each invariant name after INVARIANT keyword
155
+ const names = trimmed.replace(/^INVARIANTS?\s+/i, '').trim().split(/\s+/);
156
+ invariantCount += names.filter(n => n).length;
157
+ } else if (/^PROPERT(Y|IES)\s+\S/i.test(trimmed)) {
158
+ const names = trimmed.replace(/^PROPERT(Y|IES)\s+/i, '').trim().split(/\s+/);
159
+ propertyCount += names.filter(n => n).length;
160
+ }
161
+ // Multi-line invariant block (INVARIANTS keyword alone, names below)
162
+ // We handle this below
163
+ }
164
+
165
+ // Also handle multi-line INVARIANTS block (keyword on its own line, names below)
166
+ let inInvBlock = false;
167
+ let inPropBlock = false;
168
+ for (const rawLine of allLines) {
169
+ const trimmed = rawLine.replace(/\\?\*.*$/, '').trim();
170
+ if (!trimmed) continue;
171
+
172
+ if (/^INVARIANTS?\s*$/i.test(trimmed)) {
173
+ inInvBlock = true;
174
+ continue;
175
+ }
176
+ if (/^PROPERT(Y|IES)\s*$/i.test(trimmed)) {
177
+ inPropBlock = true;
178
+ continue;
179
+ }
180
+ if (/^(SPECIFICATION|CONSTANTS?|CHECK_DEADLOCK|SYMMETRY|CONSTRAINT|INVARIANTS?|PROPERT)\b/i.test(trimmed)) {
181
+ inInvBlock = false;
182
+ inPropBlock = false;
183
+ }
184
+
185
+ if (inInvBlock && /^[A-Za-z]/.test(trimmed)) {
186
+ invariantCount++;
187
+ }
188
+ if (inPropBlock && /^[A-Za-z]/.test(trimmed)) {
189
+ propertyCount++;
190
+ }
191
+ }
192
+
193
+ return { constants, invariant_count: invariantCount, property_count: propertyCount };
194
+ }
195
+
196
+ /**
197
+ * Parse a single constant assignment line like "MaxDeliberation = 10" or "Agents = {a1, a2}".
198
+ */
199
+ function parseConstantLine(line, constants) {
200
+ const trimmed = line.trim();
201
+ if (!trimmed) return;
202
+
203
+ const eqIdx = trimmed.indexOf('=');
204
+ if (eqIdx === -1) return;
205
+
206
+ const name = trimmed.substring(0, eqIdx).trim();
207
+ const value = trimmed.substring(eqIdx + 1).trim();
208
+
209
+ if (!name) return;
210
+
211
+ // Model value: a1 = a1 (name equals value exactly)
212
+ if (value === name) {
213
+ constants.push({ name, value, type: 'model_value' });
214
+ return;
215
+ }
216
+
217
+ // Set literal: {a1, a2, a3}
218
+ const setMatch = value.match(/^\{([^}]*)\}$/);
219
+ if (setMatch) {
220
+ const members = setMatch[1].split(',').map(s => s.trim()).filter(Boolean);
221
+ constants.push({ name, value: members, type: 'set', cardinality: members.length });
222
+ return;
223
+ }
224
+
225
+ // Integer
226
+ const intVal = parseInt(value, 10);
227
+ if (!isNaN(intVal) && String(intVal) === value) {
228
+ constants.push({ name, value: intVal, type: 'integer' });
229
+ return;
230
+ }
231
+
232
+ // Fallback — unknown format
233
+ constants.push({ name, value, type: 'unknown' });
234
+ }
235
+
236
+ /**
237
+ * Extract module name from a TLA+ file's MODULE header line.
238
+ * @param {string} tlaContent
239
+ * @returns {string|null}
240
+ */
241
+ function extractModuleName(tlaContent) {
242
+ const match = tlaContent.match(/----\s*MODULE\s+(\w+)\s*----/);
243
+ return match ? match[1] : null;
244
+ }
245
+
246
+ /**
247
+ * Extract VARIABLES from a TLA+ file.
248
+ * Returns array of variable names.
249
+ */
250
+ function extractVariables(tlaContent) {
251
+ // Find VARIABLES block — may span multiple lines
252
+ const varMatch = tlaContent.match(/\bVARIABLES?\s*\n?([\s\S]*?)(?=\n\s*\n|\nvars\s*==|\n\\?\*\s*──)/);
253
+ if (!varMatch) return [];
254
+
255
+ const block = varMatch[1];
256
+ // Variables are comma-separated, may have comments
257
+ const vars = [];
258
+ for (const part of block.split(',')) {
259
+ const name = part.replace(/\\?\*.*$/gm, '').trim().replace(/\s+.*$/, '');
260
+ if (name && /^[a-zA-Z_]\w*$/.test(name)) {
261
+ vars.push(name);
262
+ }
263
+ }
264
+ return vars;
265
+ }
266
+
267
+ /**
268
+ * Parse TypeOK invariant to extract domain for each variable.
269
+ * @param {string} tlaContent
270
+ * @param {string[]} varNames — variable names from VARIABLES block
271
+ * @param {Object[]} constants — parsed constants from .cfg
272
+ * @returns {Object[]} — array of { name, domain, cardinality, bounded }
273
+ */
274
+ function parseTypeOK(tlaContent, varNames, constants) {
275
+ // Extract TypeOK block
276
+ const typeOKMatch = tlaContent.match(/TypeOK\s*==\s*\n?([\s\S]*?)(?=\n\s*\n\s*\\?\*|^\s*\n\s*[A-Z])/m);
277
+ if (!typeOKMatch) {
278
+ // No TypeOK — return unknowns for all variables
279
+ return varNames.map(name => ({
280
+ name, domain: 'unknown', cardinality: null, bounded: false,
281
+ }));
282
+ }
283
+
284
+ const typeOKBlock = typeOKMatch[1];
285
+
286
+ // Build constant value map for resolving ranges
287
+ const constMap = {};
288
+ for (const c of constants) {
289
+ if (c.type === 'integer') constMap[c.name] = c.value;
290
+ if (c.type === 'set') constMap[c.name] = c.cardinality;
291
+ }
292
+
293
+ const results = [];
294
+
295
+ for (const varName of varNames) {
296
+ // Look for: /\ varName \in <domain>
297
+ // Also handle: /\ varName \in <domain> \* comment
298
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
299
+ const domainRegex = new RegExp('/\\\\\\s*' + escapedVar + '\\s*\\\\in\\s+(.+?)(?:\\s*\\\\\\*.*)?$', 'm');
300
+ const match = typeOKBlock.match(domainRegex);
301
+
302
+ if (!match) {
303
+ // Try alternative: variable \subseteq
304
+ const subsetRegex = new RegExp('/\\\\\\s*' + escapedVar + '\\s*\\\\subseteq\\s+(.+?)(?:\\s*\\\\\\*.*)?$', 'm');
305
+ const subMatch = typeOKBlock.match(subsetRegex);
306
+ if (subMatch) {
307
+ const domain = subMatch[1].trim();
308
+ // SUBSET = powerset
309
+ const baseCard = resolveCardinality(domain, constMap);
310
+ const card = baseCard !== null ? Math.pow(2, baseCard) : null;
311
+ results.push({
312
+ name: varName,
313
+ domain: 'SUBSET ' + domain,
314
+ cardinality: card,
315
+ bounded: card !== null,
316
+ });
317
+ continue;
318
+ }
319
+
320
+ results.push({ name: varName, domain: 'unknown', cardinality: null, bounded: false });
321
+ continue;
322
+ }
323
+
324
+ const domainStr = match[1].trim();
325
+ const parsed = parseDomain(domainStr, constMap);
326
+ results.push({ name: varName, ...parsed });
327
+ }
328
+
329
+ return results;
330
+ }
331
+
332
+ /**
333
+ * Parse a single domain expression and return { domain, cardinality, bounded }.
334
+ */
335
+ function parseDomain(domainStr, constMap) {
336
+ // BOOLEAN
337
+ if (domainStr === 'BOOLEAN' || domainStr === '{TRUE, FALSE}') {
338
+ return { domain: 'BOOLEAN', cardinality: 2, bounded: true };
339
+ }
340
+
341
+ // Nat / Int — unbounded
342
+ if (/^Nat\b/.test(domainStr)) {
343
+ return { domain: 'Nat', cardinality: null, bounded: false };
344
+ }
345
+ if (/^Int\b/.test(domainStr)) {
346
+ return { domain: 'Int', cardinality: null, bounded: false };
347
+ }
348
+
349
+ // Seq(...) — unbounded
350
+ if (/^Seq\(/.test(domainStr)) {
351
+ return { domain: domainStr, cardinality: null, bounded: false };
352
+ }
353
+
354
+ // String set literal: {"A", "B", "C"} or {A, B, C}
355
+ const setMatch = domainStr.match(/^\{([^}]+)\}$/);
356
+ if (setMatch) {
357
+ const members = setMatch[1].split(',').map(s => s.trim()).filter(Boolean);
358
+ return { domain: domainStr, cardinality: members.length, bounded: true };
359
+ }
360
+
361
+ // Range: 0..N or -(N)..N
362
+ const rangeMatch = domainStr.match(/^(-?\d+|\(-?\w+\)|-?\w+)\.\.(-?\d+|\(-?\w+\)|-?\w+)$/);
363
+ if (rangeMatch) {
364
+ const lo = resolveValue(rangeMatch[1], constMap);
365
+ const hi = resolveValue(rangeMatch[2], constMap);
366
+ if (lo !== null && hi !== null) {
367
+ const card = hi - lo + 1;
368
+ return { domain: domainStr, cardinality: card > 0 ? card : null, bounded: card > 0 };
369
+ }
370
+ // Cannot resolve — treat as bounded but unknown size
371
+ return { domain: domainStr, cardinality: null, bounded: false };
372
+ }
373
+
374
+ // SUBSET <set>
375
+ if (/^SUBSET\s+/.test(domainStr)) {
376
+ const inner = domainStr.replace(/^SUBSET\s+/, '');
377
+ const innerCard = resolveCardinality(inner, constMap);
378
+ if (innerCard !== null) {
379
+ return { domain: domainStr, cardinality: Math.pow(2, innerCard), bounded: true };
380
+ }
381
+ return { domain: domainStr, cardinality: null, bounded: false };
382
+ }
383
+
384
+ // Function space: [S -> T]
385
+ const funcMatch = domainStr.match(/^\[(.+)\s*->\s*(.+)\]$/);
386
+ if (funcMatch) {
387
+ const domCard = resolveCardinality(funcMatch[1].trim(), constMap);
388
+ const ranCard = resolveCardinality(funcMatch[2].trim(), constMap);
389
+ if (domCard !== null && ranCard !== null) {
390
+ return { domain: domainStr, cardinality: Math.pow(ranCard, domCard), bounded: true };
391
+ }
392
+ return { domain: domainStr, cardinality: null, bounded: false };
393
+ }
394
+
395
+ // Named constant reference (e.g., VoteStates, FilterPhases, FsmStates)
396
+ // These are typically defined as finite sets in the .tla — treat as bounded with unknown card
397
+ if (/^[A-Z]\w*$/.test(domainStr)) {
398
+ const card = resolveCardinality(domainStr, constMap);
399
+ if (card !== null) {
400
+ return { domain: domainStr, cardinality: card, bounded: true };
401
+ }
402
+ // Named constant we can't resolve — optimistic: assume bounded, small
403
+ return { domain: domainStr, cardinality: null, bounded: true };
404
+ }
405
+
406
+ // Record type: [type: X, target: Y]
407
+ const recordMatch = domainStr.match(/^\[(.+)\]$/);
408
+ if (recordMatch && recordMatch[1].includes(':')) {
409
+ // Parse fields
410
+ const fields = recordMatch[1].split(',');
411
+ let totalCard = 1;
412
+ let allBounded = true;
413
+ for (const field of fields) {
414
+ const parts = field.split(':');
415
+ if (parts.length >= 2) {
416
+ const fieldDomain = parts.slice(1).join(':').trim();
417
+ const fieldCard = resolveCardinality(fieldDomain, constMap);
418
+ if (fieldCard !== null) {
419
+ totalCard *= fieldCard;
420
+ } else {
421
+ allBounded = false;
422
+ }
423
+ }
424
+ }
425
+ return {
426
+ domain: domainStr,
427
+ cardinality: allBounded ? totalCard : null,
428
+ bounded: allBounded,
429
+ };
430
+ }
431
+
432
+ // Union: X \union Y
433
+ if (domainStr.includes('\\union')) {
434
+ const parts = domainStr.split('\\union').map(s => s.trim());
435
+ let totalCard = 0;
436
+ let allResolved = true;
437
+ for (const part of parts) {
438
+ const card = resolveCardinality(part, constMap);
439
+ if (card !== null) {
440
+ totalCard += card;
441
+ } else {
442
+ // Check for singleton like {NoAccount}
443
+ const singletonMatch = part.match(/^\{(\w+)\}$/);
444
+ if (singletonMatch) {
445
+ totalCard += 1;
446
+ } else {
447
+ allResolved = false;
448
+ }
449
+ }
450
+ }
451
+ return {
452
+ domain: domainStr,
453
+ cardinality: allResolved ? totalCard : null,
454
+ bounded: allResolved,
455
+ };
456
+ }
457
+
458
+ // Fallback — unknown domain
459
+ return { domain: domainStr, cardinality: null, bounded: false };
460
+ }
461
+
462
+ /**
463
+ * Resolve a value (might be a constant name or literal number).
464
+ */
465
+ function resolveValue(expr, constMap) {
466
+ const cleaned = expr.replace(/[()]/g, '').trim();
467
+ // Literal number
468
+ const num = parseInt(cleaned, 10);
469
+ if (!isNaN(num) && String(num) === cleaned) return num;
470
+ // Negative number
471
+ if (/^-\d+$/.test(cleaned)) return parseInt(cleaned, 10);
472
+ // Constant reference
473
+ if (constMap[cleaned] !== undefined) return constMap[cleaned];
474
+ return null;
475
+ }
476
+
477
+ /**
478
+ * Resolve the cardinality of a set expression.
479
+ */
480
+ function resolveCardinality(expr, constMap) {
481
+ const trimmed = expr.trim();
482
+
483
+ // Known constant cardinality
484
+ if (constMap[trimmed] !== undefined) {
485
+ return typeof constMap[trimmed] === 'number' ? constMap[trimmed] : null;
486
+ }
487
+
488
+ // Set literal
489
+ const setMatch = trimmed.match(/^\{([^}]+)\}$/);
490
+ if (setMatch) {
491
+ return setMatch[1].split(',').map(s => s.trim()).filter(Boolean).length;
492
+ }
493
+
494
+ // Range N..M
495
+ const rangeMatch = trimmed.match(/^(-?\d+|\w+)\.\.(-?\d+|\w+)$/);
496
+ if (rangeMatch) {
497
+ const lo = resolveValue(rangeMatch[1], constMap);
498
+ const hi = resolveValue(rangeMatch[2], constMap);
499
+ if (lo !== null && hi !== null) return hi - lo + 1;
500
+ }
501
+
502
+ // Nat / Int — unbounded
503
+ if (trimmed === 'Nat' || trimmed === 'Int') return null;
504
+
505
+ // BOOLEAN
506
+ if (trimmed === 'BOOLEAN' || trimmed === '{TRUE, FALSE}') return 2;
507
+
508
+ return null;
509
+ }
510
+
511
+ // ── Cross-Model Decomposition (DECOMP-05) ───────────────────────────────────
512
+
513
+ /**
514
+ * Extract requirement ID prefixes from a requirements array.
515
+ * E.g., ['DETECT-01', 'DETECT-02', 'CRED-03'] → Set {'DETECT', 'CRED'}
516
+ * @param {string[]|undefined|null} requirements
517
+ * @returns {Set<string>}
518
+ */
519
+ function extractRequirementPrefixes(requirements) {
520
+ if (!requirements || !Array.isArray(requirements) || requirements.length === 0) {
521
+ return new Set();
522
+ }
523
+ const prefixes = new Set();
524
+ for (const reqId of requirements) {
525
+ if (typeof reqId !== 'string') continue;
526
+ const parts = reqId.split('-');
527
+ if (parts.length >= 2) {
528
+ // Take all parts except the last (numeric suffix)
529
+ prefixes.add(parts.slice(0, -1).join('-'));
530
+ }
531
+ }
532
+ return prefixes;
533
+ }
534
+
535
+ /**
536
+ * Find candidate model pairs that share source files or requirement prefixes.
537
+ * @param {Object} registry — parsed model-registry.json
538
+ * @param {Object} modelAnalyses — analyzed models keyed by path
539
+ * @returns {Array<{model_a: string, model_b: string, shared_source_files: string[], shared_requirement_prefixes: string[], shared_requirements: string[]}>}
540
+ */
541
+ function findCandidatePairs(registry, modelAnalyses) {
542
+ const modelKeys = Object.keys(modelAnalyses).sort();
543
+ const pairs = [];
544
+
545
+ for (let i = 0; i < modelKeys.length; i++) {
546
+ for (let j = i + 1; j < modelKeys.length; j++) {
547
+ const a = modelKeys[i];
548
+ const b = modelKeys[j];
549
+
550
+ const regA = (registry.models || {})[a] || {};
551
+ const regB = (registry.models || {})[b] || {};
552
+
553
+ const sourceFilesA = regA.source_files || [];
554
+ const sourceFilesB = regB.source_files || [];
555
+ const reqsA = regA.requirements || [];
556
+ const reqsB = regB.requirements || [];
557
+
558
+ // Shared source files
559
+ const sharedSourceFiles = sourceFilesA.filter(function(f) {
560
+ return sourceFilesB.includes(f);
561
+ });
562
+
563
+ // Shared requirement prefixes
564
+ const prefixesA = extractRequirementPrefixes(reqsA);
565
+ const prefixesB = extractRequirementPrefixes(reqsB);
566
+ const sharedPrefixes = [];
567
+ for (const p of prefixesA) {
568
+ if (prefixesB.has(p)) sharedPrefixes.push(p);
569
+ }
570
+
571
+ // Shared exact requirements
572
+ const sharedRequirements = reqsA.filter(function(r) {
573
+ return reqsB.includes(r);
574
+ });
575
+
576
+ if (sharedSourceFiles.length > 0 || sharedPrefixes.length > 0) {
577
+ pairs.push({
578
+ model_a: a,
579
+ model_b: b,
580
+ shared_source_files: sharedSourceFiles,
581
+ shared_requirement_prefixes: sharedPrefixes.sort(),
582
+ shared_requirements: sharedRequirements.sort(),
583
+ });
584
+ }
585
+ }
586
+ }
587
+
588
+ return pairs;
589
+ }
590
+
591
+ /**
592
+ * Estimate merged state-space for two models.
593
+ * @param {Object} analysisA — model analysis from analyzeModel
594
+ * @param {Object} analysisB — model analysis from analyzeModel
595
+ * @returns {number|null} — merged state-space estimate, or null if either is unbounded/unresolvable
596
+ */
597
+ function estimateMergedStateSpace(analysisA, analysisB) {
598
+ if (analysisA.estimated_states !== null && analysisB.estimated_states !== null) {
599
+ return analysisA.estimated_states * analysisB.estimated_states;
600
+ }
601
+ return null;
602
+ }
603
+
604
+ /**
605
+ * Classify a candidate pair: recommend merge or flag interface-contract.
606
+ * @param {Object} pair — from findCandidatePairs
607
+ * @param {number|null} mergedStates — from estimateMergedStateSpace
608
+ * @param {Object} budget — { max_merged_states }
609
+ * @returns {Object} — extended pair with model_a_states, model_b_states, estimated_merged_states, recommendation, rationale
610
+ */
611
+ function classifyPair(pair, mergedStates, budget) {
612
+ let recommendation;
613
+ let rationale;
614
+
615
+ if (mergedStates !== null && mergedStates <= budget.max_merged_states) {
616
+ recommendation = 'merge';
617
+ rationale = 'Combined state-space (' + mergedStates + ') is within 5-minute TLC budget (' + budget.max_merged_states + ')';
618
+ } else if (mergedStates !== null) {
619
+ recommendation = 'interface-contract';
620
+ rationale = 'Combined state-space (' + mergedStates + ') exceeds 5-minute TLC budget (' + budget.max_merged_states + ')';
621
+ } else {
622
+ recommendation = 'interface-contract';
623
+ rationale = 'One or both models have unbounded state-space; interface contract required';
624
+ }
625
+
626
+ return Object.assign({}, pair, {
627
+ estimated_merged_states: mergedStates,
628
+ recommendation: recommendation,
629
+ rationale: rationale,
630
+ });
631
+ }
632
+
633
+ /**
634
+ * Analyze cross-model relationships: detect pairs, estimate merged state-space,
635
+ * and recommend merge or interface-contract.
636
+ * @param {Object} registry — parsed model-registry.json
637
+ * @param {Object} models — analyzed models (from analyzeModel)
638
+ * @returns {{ budget: Object, pairs: Object[], summary: Object }}
639
+ */
640
+ function analyzeCrossModel(registry, models) {
641
+ const pairs = findCandidatePairs(registry, models);
642
+
643
+ const classifiedPairs = pairs.map(function(pair) {
644
+ var analysisA = models[pair.model_a];
645
+ var analysisB = models[pair.model_b];
646
+ var mergedStates = estimateMergedStateSpace(analysisA, analysisB);
647
+ return classifyPair(pair, mergedStates, MERGE_BUDGET);
648
+ });
649
+
650
+ var mergeRecommended = 0;
651
+ var interfaceContractNeeded = 0;
652
+ for (var k = 0; k < classifiedPairs.length; k++) {
653
+ if (classifiedPairs[k].recommendation === 'merge') mergeRecommended++;
654
+ else interfaceContractNeeded++;
655
+ }
656
+
657
+ return {
658
+ budget: {
659
+ max_tlc_seconds: MERGE_BUDGET.max_tlc_seconds,
660
+ throughput_states_per_sec: MERGE_BUDGET.throughput_states_per_sec,
661
+ max_merged_states: MERGE_BUDGET.max_merged_states,
662
+ },
663
+ pairs: classifiedPairs,
664
+ summary: {
665
+ total_pairs_analyzed: classifiedPairs.length,
666
+ merge_recommended: mergeRecommended,
667
+ interface_contract_needed: interfaceContractNeeded,
668
+ },
669
+ };
670
+ }
671
+
672
+ // ── Model Analysis ──────────────────────────────────────────────────────────
673
+
674
+ /**
675
+ * Build reverse map: TLA module name → cfg file path(s)
676
+ */
677
+ function buildModuleToCfgMap() {
678
+ const map = {}; // moduleName -> [cfgPath, ...]
679
+
680
+ if (!fs.existsSync(TLA_DIR)) return map;
681
+
682
+ const cfgFiles = fs.readdirSync(TLA_DIR).filter(f => f.endsWith('.cfg'));
683
+
684
+ for (const cfgFile of cfgFiles) {
685
+ const baseName = cfgFile.replace('.cfg', '');
686
+ const moduleName = CFG_TO_MODULE[baseName];
687
+
688
+ if (moduleName) {
689
+ if (!map[moduleName]) map[moduleName] = [];
690
+ map[moduleName].push(path.join(TLA_DIR, cfgFile));
691
+ }
692
+ }
693
+
694
+ return map;
695
+ }
696
+
697
+ /**
698
+ * Choose the best .cfg for a module (prefer one with CONSTANTS).
699
+ */
700
+ function chooseBestCfg(cfgPaths) {
701
+ if (!cfgPaths || cfgPaths.length === 0) return null;
702
+ if (cfgPaths.length === 1) return cfgPaths[0];
703
+
704
+ // Prefer cfg with CONSTANTS
705
+ for (const cfgPath of cfgPaths) {
706
+ try {
707
+ const content = fs.readFileSync(cfgPath, 'utf8');
708
+ if (/^CONSTANTS?\b/im.test(content)) return cfgPath;
709
+ } catch (_) { /* ignore */ }
710
+ }
711
+
712
+ return cfgPaths[0];
713
+ }
714
+
715
+ /**
716
+ * Analyze a single TLA+ model.
717
+ */
718
+ function analyzeModel(tlaRelPath, moduleToCfg) {
719
+ const tlaAbsPath = path.join(ROOT, tlaRelPath);
720
+
721
+ let tlaContent;
722
+ try {
723
+ tlaContent = fs.readFileSync(tlaAbsPath, 'utf8');
724
+ } catch (err) {
725
+ process.stderr.write(TAG + ' warn: cannot read ' + tlaRelPath + ': ' + err.message + '\n');
726
+ return {
727
+ module_name: path.basename(tlaRelPath, '.tla'),
728
+ cfg_file: null,
729
+ variables: [],
730
+ constants: [],
731
+ estimated_states: null,
732
+ has_unbounded: false,
733
+ unbounded_domains: [],
734
+ risk_level: 'MODERATE',
735
+ risk_reason: 'Parse error — conservative default',
736
+ invariant_count: 0,
737
+ property_count: 0,
738
+ };
739
+ }
740
+
741
+ const moduleName = extractModuleName(tlaContent) || path.basename(tlaRelPath, '.tla');
742
+
743
+ // Find cfg
744
+ const cfgPaths = moduleToCfg[moduleName] || [];
745
+ const cfgPath = chooseBestCfg(cfgPaths);
746
+ const cfgRelPath = cfgPath ? path.relative(ROOT, cfgPath) : null;
747
+
748
+ // Parse cfg
749
+ let cfgData = { constants: [], invariant_count: 0, property_count: 0 };
750
+ if (cfgPath) {
751
+ cfgData = parseCfg(cfgPath);
752
+ }
753
+
754
+ // Filter out model_value constants (symmetry set members)
755
+ const meaningfulConstants = cfgData.constants.filter(c => c.type !== 'model_value');
756
+
757
+ // Parse TLA+ variables
758
+ const varNames = extractVariables(tlaContent);
759
+
760
+ // Parse TypeOK domains
761
+ const variables = parseTypeOK(tlaContent, varNames, cfgData.constants);
762
+
763
+ // Compute state-space estimate
764
+ let estimatedStates = 1;
765
+ let hasUnbounded = false;
766
+ const unboundedDomains = [];
767
+
768
+ for (const v of variables) {
769
+ if (!v.bounded) {
770
+ hasUnbounded = true;
771
+ unboundedDomains.push(v.name + ': ' + v.domain);
772
+ estimatedStates = null;
773
+ } else if (v.cardinality !== null && estimatedStates !== null) {
774
+ estimatedStates *= v.cardinality;
775
+ } else if (v.cardinality === null && estimatedStates !== null) {
776
+ // Bounded but unknown cardinality — can't compute total
777
+ // Don't mark as unbounded, but we can't estimate
778
+ estimatedStates = null;
779
+ }
780
+ }
781
+
782
+ if (variables.length === 0) {
783
+ estimatedStates = null;
784
+ }
785
+
786
+ // Risk classification
787
+ let riskLevel;
788
+ let riskReason;
789
+
790
+ if (hasUnbounded) {
791
+ riskLevel = 'HIGH';
792
+ riskReason = 'Unbounded domains: ' + unboundedDomains.join(', ');
793
+ } else if (estimatedStates === null) {
794
+ riskLevel = 'MODERATE';
795
+ riskReason = 'State-space could not be fully estimated (some domains unresolvable)';
796
+ } else if (estimatedStates <= DEFAULT_THRESHOLDS.MINIMAL) {
797
+ riskLevel = 'MINIMAL';
798
+ riskReason = 'Estimated ' + estimatedStates + ' states (<= ' + DEFAULT_THRESHOLDS.MINIMAL + ')';
799
+ } else if (estimatedStates <= DEFAULT_THRESHOLDS.LOW) {
800
+ riskLevel = 'LOW';
801
+ riskReason = 'Estimated ' + estimatedStates + ' states (<= ' + DEFAULT_THRESHOLDS.LOW + ')';
802
+ } else if (estimatedStates <= DEFAULT_THRESHOLDS.MODERATE) {
803
+ riskLevel = 'MODERATE';
804
+ riskReason = 'Estimated ' + estimatedStates + ' states (<= ' + DEFAULT_THRESHOLDS.MODERATE + ')';
805
+ } else {
806
+ riskLevel = 'HIGH';
807
+ riskReason = 'Estimated ' + estimatedStates + ' states (> ' + DEFAULT_THRESHOLDS.MODERATE + ')';
808
+ }
809
+
810
+ return {
811
+ module_name: moduleName,
812
+ cfg_file: cfgRelPath,
813
+ variables,
814
+ constants: meaningfulConstants,
815
+ estimated_states: estimatedStates,
816
+ has_unbounded: hasUnbounded,
817
+ unbounded_domains: unboundedDomains,
818
+ risk_level: riskLevel,
819
+ risk_reason: riskReason,
820
+ invariant_count: cfgData.invariant_count,
821
+ property_count: cfgData.property_count,
822
+ };
823
+ }
824
+
825
+ // ── Main ────────────────────────────────────────────────────────────────────
826
+
827
+ function main() {
828
+ // Load model registry
829
+ if (!fs.existsSync(REGISTRY_PATH)) {
830
+ process.stderr.write(TAG + ' FATAL: model-registry.json not found at ' + REGISTRY_PATH + '\n');
831
+ process.exit(1);
832
+ }
833
+
834
+ const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
835
+
836
+ // Find TLA+ models (filter out TTrace files and non-local paths)
837
+ const tlaModels = Object.keys(registry.models || {}).filter(key => {
838
+ return key.endsWith('.tla')
839
+ && !key.includes('_TTrace_')
840
+ && key.startsWith('.planning/formal/tla/')
841
+ && !key.startsWith('../../../../'); // skip test paths
842
+ });
843
+
844
+ // Build cfg lookup
845
+ const moduleToCfg = buildModuleToCfgMap();
846
+
847
+ // Analyze each model
848
+ const models = {};
849
+ for (const tlaRelPath of tlaModels) {
850
+ try {
851
+ models[tlaRelPath] = analyzeModel(tlaRelPath, moduleToCfg);
852
+ } catch (err) {
853
+ process.stderr.write(TAG + ' warn: error analyzing ' + tlaRelPath + ': ' + err.message + '\n');
854
+ models[tlaRelPath] = {
855
+ module_name: path.basename(tlaRelPath, '.tla'),
856
+ cfg_file: null,
857
+ variables: [],
858
+ constants: [],
859
+ estimated_states: null,
860
+ has_unbounded: false,
861
+ unbounded_domains: [],
862
+ risk_level: 'MODERATE',
863
+ risk_reason: 'Analysis error — conservative default',
864
+ invariant_count: 0,
865
+ property_count: 0,
866
+ };
867
+ }
868
+ }
869
+
870
+ // Build summary
871
+ const byRisk = { MINIMAL: 0, LOW: 0, MODERATE: 0, HIGH: 0 };
872
+ let unboundedCount = 0;
873
+ let modelsWithoutCfg = 0;
874
+
875
+ for (const m of Object.values(models)) {
876
+ byRisk[m.risk_level] = (byRisk[m.risk_level] || 0) + 1;
877
+ if (m.has_unbounded) unboundedCount++;
878
+ if (!m.cfg_file) modelsWithoutCfg++;
879
+ }
880
+
881
+ // Cross-model decomposition analysis (DECOMP-05)
882
+ const crossModel = analyzeCrossModel(registry, models);
883
+
884
+ const report = {
885
+ metadata: {
886
+ generated_at: new Date().toISOString(),
887
+ generator: 'analyze-state-space',
888
+ version: '1.0',
889
+ thresholds: { ...DEFAULT_THRESHOLDS },
890
+ },
891
+ models,
892
+ summary: {
893
+ total_models: tlaModels.length,
894
+ by_risk: byRisk,
895
+ unbounded_count: unboundedCount,
896
+ models_without_cfg: modelsWithoutCfg,
897
+ },
898
+ cross_model: crossModel,
899
+ };
900
+
901
+ const jsonStr = JSON.stringify(report, null, 2);
902
+
903
+ if (jsonMode) {
904
+ process.stdout.write(jsonStr + '\n');
905
+ return;
906
+ }
907
+
908
+ // Write to file
909
+ fs.writeFileSync(OUTPUT_PATH, jsonStr + '\n', 'utf8');
910
+
911
+ if (!quietMode) {
912
+ process.stdout.write(TAG + ' Analyzed ' + tlaModels.length + ' TLA+ models\n');
913
+ process.stdout.write(TAG + ' MINIMAL: ' + byRisk.MINIMAL + ' LOW: ' + byRisk.LOW + ' MODERATE: ' + byRisk.MODERATE + ' HIGH: ' + byRisk.HIGH + '\n');
914
+ process.stdout.write(TAG + ' Unbounded domains: ' + unboundedCount + ' model(s)\n');
915
+ process.stdout.write(TAG + ' Models without .cfg: ' + modelsWithoutCfg + '\n');
916
+ process.stdout.write(TAG + ' Cross-model pairs: ' + crossModel.summary.total_pairs_analyzed + ' (merge: ' + crossModel.summary.merge_recommended + ', interface-contract: ' + crossModel.summary.interface_contract_needed + ')\n');
917
+ process.stdout.write(TAG + ' Report: .planning/formal/state-space-report.json\n');
918
+ }
919
+ }
920
+
921
+ main();