@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.
- package/LICENSE +22 -0
- package/README.md +1024 -0
- package/agents/qgsd-codebase-mapper.md +764 -0
- package/agents/qgsd-debugger.md +1201 -0
- package/agents/qgsd-executor.md +472 -0
- package/agents/qgsd-integration-checker.md +443 -0
- package/agents/qgsd-phase-researcher.md +502 -0
- package/agents/qgsd-plan-checker.md +643 -0
- package/agents/qgsd-planner.md +1182 -0
- package/agents/qgsd-project-researcher.md +621 -0
- package/agents/qgsd-quorum-orchestrator.md +628 -0
- package/agents/qgsd-quorum-slot-worker.md +41 -0
- package/agents/qgsd-quorum-synthesizer.md +133 -0
- package/agents/qgsd-quorum-test-worker.md +37 -0
- package/agents/qgsd-quorum-worker.md +161 -0
- package/agents/qgsd-research-synthesizer.md +239 -0
- package/agents/qgsd-roadmapper.md +660 -0
- package/agents/qgsd-verifier.md +628 -0
- package/bin/accept-debug-invariant.cjs +165 -0
- package/bin/account-manager.cjs +719 -0
- package/bin/aggregate-requirements.cjs +466 -0
- package/bin/analyze-assumptions.cjs +757 -0
- package/bin/analyze-state-space.cjs +921 -0
- package/bin/attribute-trace-divergence.cjs +150 -0
- package/bin/auth-drivers/gh-cli.cjs +93 -0
- package/bin/auth-drivers/index.cjs +46 -0
- package/bin/auth-drivers/pool.cjs +67 -0
- package/bin/auth-drivers/simple.cjs +95 -0
- package/bin/autoClosePtoF.cjs +110 -0
- package/bin/blessed-terminal.cjs +350 -0
- package/bin/build-phase-index.cjs +472 -0
- package/bin/call-quorum-slot.cjs +541 -0
- package/bin/ccr-secure-config.cjs +99 -0
- package/bin/ccr-secure-start.cjs +83 -0
- package/bin/check-bundled-sdks.cjs +177 -0
- package/bin/check-coverage-guard.cjs +112 -0
- package/bin/check-liveness-fairness.cjs +95 -0
- package/bin/check-mcp-health.cjs +123 -0
- package/bin/check-provider-health.cjs +395 -0
- package/bin/check-results-exit.cjs +24 -0
- package/bin/check-spec-sync.cjs +360 -0
- package/bin/check-trace-redaction.cjs +271 -0
- package/bin/check-trace-schema-drift.cjs +99 -0
- package/bin/compareDrift.cjs +21 -0
- package/bin/conformance-schema.cjs +12 -0
- package/bin/count-scenarios.cjs +420 -0
- package/bin/debt-dedup.cjs +144 -0
- package/bin/debt-ledger.cjs +61 -0
- package/bin/debt-retention.cjs +76 -0
- package/bin/debt-state-machine.cjs +80 -0
- package/bin/detect-coverage-gaps.cjs +204 -0
- package/bin/detect-project-intent.cjs +362 -0
- package/bin/export-prism-constants.cjs +164 -0
- package/bin/extract-annotations.cjs +633 -0
- package/bin/extractFormalExpected.cjs +104 -0
- package/bin/fingerprint-drift.cjs +24 -0
- package/bin/fingerprint-issue.cjs +46 -0
- package/bin/formal-core.cjs +519 -0
- package/bin/formal-ref-linker.cjs +141 -0
- package/bin/formal-test-sync.cjs +788 -0
- package/bin/generate-formal-specs.cjs +588 -0
- package/bin/generate-petri-net.cjs +397 -0
- package/bin/generate-phase-spec.cjs +249 -0
- package/bin/generate-proposed-changes.cjs +194 -0
- package/bin/generate-tla-cfg.cjs +122 -0
- package/bin/generate-traceability-matrix.cjs +701 -0
- package/bin/generate-triage-bundle.cjs +300 -0
- package/bin/gh-account-rotate.cjs +34 -0
- package/bin/initialize-model-registry.cjs +105 -0
- package/bin/install-formal-tools.cjs +382 -0
- package/bin/install.js +2424 -0
- package/bin/isNumericThreshold.cjs +34 -0
- package/bin/issue-classifier.cjs +151 -0
- package/bin/levenshtein.cjs +74 -0
- package/bin/lint-formal-models.cjs +580 -0
- package/bin/load-baseline-requirements.cjs +275 -0
- package/bin/manage-agents-core.cjs +815 -0
- package/bin/migrate-formal-dir.cjs +172 -0
- package/bin/migrate-planning.cjs +206 -0
- package/bin/migrate-to-slots.cjs +255 -0
- package/bin/nForma.cjs +2726 -0
- package/bin/observe-config.cjs +353 -0
- package/bin/observe-debt-writer.cjs +140 -0
- package/bin/observe-handler-grafana.cjs +128 -0
- package/bin/observe-handler-internal.cjs +301 -0
- package/bin/observe-handler-logstash.cjs +153 -0
- package/bin/observe-handler-prometheus.cjs +185 -0
- package/bin/observe-handlers.cjs +436 -0
- package/bin/observe-registry.cjs +131 -0
- package/bin/observe-render.cjs +168 -0
- package/bin/planning-paths.cjs +167 -0
- package/bin/polyrepo.cjs +560 -0
- package/bin/prism-priority.cjs +153 -0
- package/bin/probe-quorum-slots.cjs +167 -0
- package/bin/promote-model.cjs +225 -0
- package/bin/propose-debug-invariants.cjs +165 -0
- package/bin/providers.json +392 -0
- package/bin/pty-proxy.py +129 -0
- package/bin/qgsd-solve.cjs +2477 -0
- package/bin/quorum-consensus-gate.cjs +238 -0
- package/bin/quorum-formal-context.cjs +183 -0
- package/bin/quorum-slot-dispatch.cjs +934 -0
- package/bin/read-policy.cjs +60 -0
- package/bin/requirement-map.cjs +63 -0
- package/bin/requirements-core.cjs +247 -0
- package/bin/resolve-cli.cjs +101 -0
- package/bin/review-mcp-logs.cjs +294 -0
- package/bin/run-account-manager-tlc.cjs +188 -0
- package/bin/run-account-pool-alloy.cjs +158 -0
- package/bin/run-alloy.cjs +153 -0
- package/bin/run-audit-alloy.cjs +187 -0
- package/bin/run-breaker-tlc.cjs +181 -0
- package/bin/run-formal-check.cjs +395 -0
- package/bin/run-formal-verify.cjs +701 -0
- package/bin/run-installer-alloy.cjs +188 -0
- package/bin/run-oauth-rotation-prism.cjs +132 -0
- package/bin/run-oscillation-tlc.cjs +202 -0
- package/bin/run-phase-tlc.cjs +228 -0
- package/bin/run-prism.cjs +446 -0
- package/bin/run-protocol-tlc.cjs +201 -0
- package/bin/run-quorum-composition-alloy.cjs +155 -0
- package/bin/run-sensitivity-sweep.cjs +231 -0
- package/bin/run-stop-hook-tlc.cjs +188 -0
- package/bin/run-tlc.cjs +467 -0
- package/bin/run-transcript-alloy.cjs +173 -0
- package/bin/run-uppaal.cjs +264 -0
- package/bin/secrets.cjs +134 -0
- package/bin/sensitivity-report.cjs +219 -0
- package/bin/sensitivity-sweep-feedback.cjs +194 -0
- package/bin/set-secret.cjs +29 -0
- package/bin/setup-telemetry-cron.sh +36 -0
- package/bin/sweepPtoF.cjs +63 -0
- package/bin/sync-baseline-requirements.cjs +290 -0
- package/bin/task-envelope.cjs +360 -0
- package/bin/telemetry-collector.cjs +229 -0
- package/bin/unified-mcp-server.mjs +735 -0
- package/bin/update-agents.cjs +369 -0
- package/bin/update-scoreboard.cjs +1134 -0
- package/bin/validate-debt-entry.cjs +207 -0
- package/bin/validate-invariant.cjs +419 -0
- package/bin/validate-memory.cjs +389 -0
- package/bin/validate-requirements-haiku.cjs +435 -0
- package/bin/validate-traces.cjs +438 -0
- package/bin/verify-formal-results.cjs +124 -0
- package/bin/verify-quorum-health.cjs +273 -0
- package/bin/write-check-result.cjs +106 -0
- package/bin/xstate-to-tla.cjs +483 -0
- package/bin/xstate-trace-walker.cjs +205 -0
- package/commands/qgsd/add-phase.md +43 -0
- package/commands/qgsd/add-requirement.md +24 -0
- package/commands/qgsd/add-todo.md +47 -0
- package/commands/qgsd/audit-milestone.md +37 -0
- package/commands/qgsd/check-todos.md +45 -0
- package/commands/qgsd/cleanup.md +18 -0
- package/commands/qgsd/close-formal-gaps.md +33 -0
- package/commands/qgsd/complete-milestone.md +136 -0
- package/commands/qgsd/debug.md +166 -0
- package/commands/qgsd/discuss-phase.md +83 -0
- package/commands/qgsd/execute-phase.md +117 -0
- package/commands/qgsd/fix-tests.md +27 -0
- package/commands/qgsd/formal-test-sync.md +32 -0
- package/commands/qgsd/health.md +22 -0
- package/commands/qgsd/help.md +22 -0
- package/commands/qgsd/insert-phase.md +32 -0
- package/commands/qgsd/join-discord.md +18 -0
- package/commands/qgsd/list-phase-assumptions.md +46 -0
- package/commands/qgsd/map-codebase.md +71 -0
- package/commands/qgsd/map-requirements.md +20 -0
- package/commands/qgsd/mcp-restart.md +176 -0
- package/commands/qgsd/mcp-set-model.md +134 -0
- package/commands/qgsd/mcp-setup.md +1371 -0
- package/commands/qgsd/mcp-status.md +274 -0
- package/commands/qgsd/mcp-update.md +238 -0
- package/commands/qgsd/new-milestone.md +44 -0
- package/commands/qgsd/new-project.md +42 -0
- package/commands/qgsd/observe.md +260 -0
- package/commands/qgsd/pause-work.md +38 -0
- package/commands/qgsd/plan-milestone-gaps.md +34 -0
- package/commands/qgsd/plan-phase.md +44 -0
- package/commands/qgsd/polyrepo.md +50 -0
- package/commands/qgsd/progress.md +24 -0
- package/commands/qgsd/queue.md +54 -0
- package/commands/qgsd/quick.md +133 -0
- package/commands/qgsd/quorum-test.md +275 -0
- package/commands/qgsd/quorum.md +707 -0
- package/commands/qgsd/reapply-patches.md +110 -0
- package/commands/qgsd/remove-phase.md +31 -0
- package/commands/qgsd/research-phase.md +189 -0
- package/commands/qgsd/resume-work.md +40 -0
- package/commands/qgsd/set-profile.md +34 -0
- package/commands/qgsd/settings.md +39 -0
- package/commands/qgsd/solve.md +565 -0
- package/commands/qgsd/sync-baselines.md +119 -0
- package/commands/qgsd/triage.md +233 -0
- package/commands/qgsd/update.md +37 -0
- package/commands/qgsd/verify-work.md +38 -0
- package/hooks/dist/config-loader.js +297 -0
- package/hooks/dist/conformance-schema.cjs +12 -0
- package/hooks/dist/gsd-context-monitor.js +64 -0
- package/hooks/dist/qgsd-check-update.js +62 -0
- package/hooks/dist/qgsd-circuit-breaker.js +682 -0
- package/hooks/dist/qgsd-precompact.js +156 -0
- package/hooks/dist/qgsd-prompt.js +653 -0
- package/hooks/dist/qgsd-session-start.js +122 -0
- package/hooks/dist/qgsd-slot-correlator.js +58 -0
- package/hooks/dist/qgsd-spec-regen.js +86 -0
- package/hooks/dist/qgsd-statusline.js +91 -0
- package/hooks/dist/qgsd-stop.js +553 -0
- package/hooks/dist/qgsd-token-collector.js +133 -0
- package/hooks/dist/unified-mcp-server.mjs +669 -0
- package/package.json +95 -0
- package/scripts/build-hooks.js +46 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/secret-audit.sh +45 -0
- 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();
|