@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,701 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/generate-traceability-matrix.cjs
4
+ // Generates .planning/formal/traceability-matrix.json — a bidirectional index linking
5
+ // requirements to formal properties and vice versa, with coverage statistics.
6
+ //
7
+ // Data sources:
8
+ // 1. extract-annotations.cjs output (primary — property-level)
9
+ // 2. .planning/formal/model-registry.json (fallback — model-level)
10
+ // 3. .planning/formal/check-results.ndjson (verification status)
11
+ // 4. .planning/formal/requirements.json (total requirement inventory)
12
+ //
13
+ // Usage:
14
+ // node bin/generate-traceability-matrix.cjs # write to .planning/formal/traceability-matrix.json
15
+ // node bin/generate-traceability-matrix.cjs --json # print JSON to stdout
16
+ // node bin/generate-traceability-matrix.cjs --quiet # suppress summary output
17
+ //
18
+ // Requirements: TRACE-01, TRACE-02, TRACE-04, ANNOT-05
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const TAG = '[generate-traceability-matrix]';
25
+ let ROOT = process.cwd();
26
+
27
+ // Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
28
+ for (const arg of process.argv.slice(2)) {
29
+ if (arg.startsWith('--project-root=')) {
30
+ ROOT = path.resolve(arg.slice('--project-root='.length));
31
+ }
32
+ }
33
+
34
+ const ANNOTATIONS_SCRIPT = path.join(__dirname, 'extract-annotations.cjs');
35
+ const REGISTRY_PATH = path.join(ROOT, '.planning', 'formal', 'model-registry.json');
36
+ const NDJSON_PATH = path.join(ROOT, '.planning', 'formal', 'check-results.ndjson');
37
+ const REQUIREMENTS_PATH = path.join(ROOT, '.planning', 'formal', 'requirements.json');
38
+ const OUTPUT_PATH = path.join(ROOT, '.planning', 'formal', 'traceability-matrix.json');
39
+
40
+ // ── CLI flags ───────────────────────────────────────────────────────────────
41
+
42
+ const args = process.argv.slice(2);
43
+ const jsonMode = args.includes('--json');
44
+ const quietMode = args.includes('--quiet');
45
+
46
+ // ── Data Loading ────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Load annotations from extract-annotations.cjs (primary source).
50
+ * Returns { model_file: [{ property, requirement_ids }] } or {} on failure.
51
+ */
52
+ function loadAnnotations() {
53
+ try {
54
+ const result = spawnSync(process.execPath, [ANNOTATIONS_SCRIPT, '--project-root=' + ROOT], {
55
+ encoding: 'utf8',
56
+ cwd: ROOT,
57
+ timeout: 30000,
58
+ });
59
+ if (result.status !== 0) {
60
+ process.stderr.write(TAG + ' warn: extract-annotations.cjs exited ' + result.status + '\n');
61
+ if (result.stderr) process.stderr.write(TAG + ' stderr: ' + result.stderr.trim() + '\n');
62
+ return {};
63
+ }
64
+ return JSON.parse(result.stdout);
65
+ } catch (err) {
66
+ process.stderr.write(TAG + ' warn: extract-annotations.cjs failed: ' + err.message + '\n');
67
+ return {};
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Load model-registry.json (fallback source).
73
+ * Returns { models: { file: { requirements: [...] } } } or throws on missing.
74
+ */
75
+ function loadRegistry() {
76
+ if (!fs.existsSync(REGISTRY_PATH)) {
77
+ process.stderr.write(TAG + ' FATAL: model-registry.json not found at ' + REGISTRY_PATH + '\n');
78
+ process.exit(1);
79
+ }
80
+ return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
81
+ }
82
+
83
+ /**
84
+ * Load check-results.ndjson (verification status).
85
+ * Returns array of parsed check result objects, or [] if missing/empty.
86
+ */
87
+ function loadCheckResults() {
88
+ if (!fs.existsSync(NDJSON_PATH)) return [];
89
+ const content = fs.readFileSync(NDJSON_PATH, 'utf8').trim();
90
+ if (!content) return [];
91
+ const entries = [];
92
+ for (const line of content.split('\n')) {
93
+ if (!line.trim()) continue;
94
+ try {
95
+ entries.push(JSON.parse(line));
96
+ } catch (err) {
97
+ process.stderr.write(TAG + ' warn: skipping invalid NDJSON line\n');
98
+ }
99
+ }
100
+ return entries;
101
+ }
102
+
103
+ /**
104
+ * Load requirements.json (reference for coverage stats).
105
+ * Returns array of requirement objects, or [] if missing.
106
+ */
107
+ function loadRequirements() {
108
+ if (!fs.existsSync(REQUIREMENTS_PATH)) {
109
+ process.stderr.write(TAG + ' warn: requirements.json not found — coverage stats will show 0 total\n');
110
+ return [];
111
+ }
112
+ try {
113
+ const data = JSON.parse(fs.readFileSync(REQUIREMENTS_PATH, 'utf8'));
114
+ return data.requirements || [];
115
+ } catch (err) {
116
+ process.stderr.write(TAG + ' warn: requirements.json parse error: ' + err.message + '\n');
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Load unit-test-coverage.json sidecar (generated by formal-test-sync.cjs).
123
+ * Returns the sidecar object or null if missing/unparseable.
124
+ */
125
+ function loadUnitTestCoverage() {
126
+ const UNIT_TEST_COVERAGE_PATH = path.join(ROOT, '.planning', 'formal', 'unit-test-coverage.json');
127
+ if (!fs.existsSync(UNIT_TEST_COVERAGE_PATH)) return null;
128
+ try {
129
+ return JSON.parse(fs.readFileSync(UNIT_TEST_COVERAGE_PATH, 'utf8'));
130
+ } catch (err) {
131
+ process.stderr.write(TAG + ' warn: unit-test-coverage.json parse error: ' + err.message + '\n');
132
+ return null;
133
+ }
134
+ }
135
+
136
+ // ── Check Result Matching ───────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Build a map of requirement_id -> latest check result from NDJSON entries.
140
+ * For each check result, distribute its result to all its requirement_ids.
141
+ * Last entry wins for a given check_id.
142
+ */
143
+ function buildCheckResultMap(checkResults) {
144
+ // Deduplicate by check_id (last entry wins)
145
+ const byCheckId = new Map();
146
+ for (const entry of checkResults) {
147
+ if (entry.check_id) {
148
+ byCheckId.set(entry.check_id, entry);
149
+ }
150
+ }
151
+
152
+ // Map: requirement_id -> { result, check_id }
153
+ const reqMap = new Map();
154
+ for (const entry of byCheckId.values()) {
155
+ const reqIds = entry.requirement_ids || [];
156
+ for (const reqId of reqIds) {
157
+ reqMap.set(reqId, {
158
+ result: entry.result,
159
+ check_id: entry.check_id,
160
+ });
161
+ }
162
+ }
163
+ return reqMap;
164
+ }
165
+
166
+ // ── Bidirectional Validation (TRACE-04) ─────────────────────────────────────
167
+
168
+ /**
169
+ * Cross-validate model-registry requirements arrays against requirements.json
170
+ * formal_models arrays. Detects asymmetric links and stale references.
171
+ *
172
+ * @param {Object} registry — parsed model-registry.json
173
+ * @param {Array} requirements — array of requirement objects from requirements.json
174
+ * @returns {{ asymmetric_links: Array, stale_links: Array, summary: Object }}
175
+ */
176
+ function validateBidirectionalLinks(registry, requirements) {
177
+ const asymmetricLinks = [];
178
+ const staleLinks = [];
179
+ const checkedPairs = new Set(); // "modelFile|reqId" to deduplicate
180
+
181
+ // Build maps
182
+ const registryReqs = {}; // modelFile -> Set<reqId>
183
+ for (const [modelFile, entry] of Object.entries(registry.models || {})) {
184
+ const reqs = entry.requirements || [];
185
+ if (reqs.length > 0) {
186
+ registryReqs[modelFile] = new Set(reqs);
187
+ }
188
+ }
189
+
190
+ const reqModels = {}; // reqId -> Set<modelFile>
191
+ const reqIdSet = new Set();
192
+ for (const req of requirements) {
193
+ reqIdSet.add(req.id);
194
+ const models = req.formal_models || [];
195
+ if (models.length > 0) {
196
+ reqModels[req.id] = new Set(models);
197
+ }
198
+ }
199
+
200
+ // Direction 1: model claims requirement — check if requirement claims model back
201
+ for (const [modelFile, reqIds] of Object.entries(registryReqs)) {
202
+ for (const reqId of reqIds) {
203
+ const pairKey = modelFile + '|' + reqId;
204
+ if (checkedPairs.has(pairKey)) continue;
205
+ checkedPairs.add(pairKey);
206
+
207
+ // Check if requirement ID exists at all
208
+ if (!reqIdSet.has(reqId)) {
209
+ staleLinks.push({
210
+ type: 'unknown_requirement_id',
211
+ reference: reqId,
212
+ referenced_by: 'model-registry ' + modelFile + ' requirements',
213
+ });
214
+ process.stderr.write(TAG + ' warn: stale link — model-registry ' + modelFile + ' references requirement ' + reqId + ' which does not exist in requirements.json\n');
215
+ continue;
216
+ }
217
+
218
+ // Check if requirement's formal_models lists this model
219
+ const modelsForReq = reqModels[reqId];
220
+ if (!modelsForReq || !modelsForReq.has(modelFile)) {
221
+ asymmetricLinks.push({
222
+ model_file: modelFile,
223
+ requirement_id: reqId,
224
+ direction: 'model_claims_requirement',
225
+ detail: 'model-registry lists ' + reqId + ' for ' + modelFile + ' but requirements.json ' + reqId + ' does not list ' + modelFile + ' in formal_models',
226
+ });
227
+ process.stderr.write(TAG + ' warn: asymmetric link — model-registry lists ' + reqId + ' for ' + modelFile + ' but requirements.json ' + reqId + ' does not list ' + modelFile + ' in formal_models\n');
228
+ }
229
+ }
230
+ }
231
+
232
+ // Direction 2: requirement claims model — check if model claims requirement back
233
+ for (const [reqId, modelFiles] of Object.entries(reqModels)) {
234
+ for (const modelFile of modelFiles) {
235
+ const pairKey = modelFile + '|' + reqId;
236
+ if (checkedPairs.has(pairKey)) {
237
+ // Already checked from direction 1 — skip
238
+ continue;
239
+ }
240
+ checkedPairs.add(pairKey);
241
+
242
+ // Check if model file exists in registry
243
+ if (!registry.models || !registry.models[modelFile]) {
244
+ // Check if file exists on disk
245
+ const diskPath = path.join(ROOT, modelFile);
246
+ if (!fs.existsSync(diskPath)) {
247
+ staleLinks.push({
248
+ type: 'missing_model_file',
249
+ reference: modelFile,
250
+ referenced_by: 'requirements.json ' + reqId + ' formal_models',
251
+ });
252
+ process.stderr.write(TAG + ' warn: stale link — requirements.json ' + reqId + ' references ' + modelFile + ' but file does not exist\n');
253
+ } else {
254
+ // File exists on disk but not in registry
255
+ asymmetricLinks.push({
256
+ model_file: modelFile,
257
+ requirement_id: reqId,
258
+ direction: 'requirement_claims_model',
259
+ detail: 'requirements.json ' + reqId + ' lists ' + modelFile + ' in formal_models but model file is not in model-registry.json',
260
+ });
261
+ process.stderr.write(TAG + ' warn: asymmetric link — requirements.json ' + reqId + ' lists ' + modelFile + ' in formal_models but model file is not in model-registry.json\n');
262
+ }
263
+ continue;
264
+ }
265
+
266
+ // Model is in registry — check if it claims this requirement back
267
+ const regsForModel = registryReqs[modelFile];
268
+ if (!regsForModel || !regsForModel.has(reqId)) {
269
+ asymmetricLinks.push({
270
+ model_file: modelFile,
271
+ requirement_id: reqId,
272
+ direction: 'requirement_claims_model',
273
+ detail: 'requirements.json ' + reqId + ' lists ' + modelFile + ' in formal_models but model-registry ' + modelFile + ' does not list ' + reqId + ' in requirements',
274
+ });
275
+ process.stderr.write(TAG + ' warn: asymmetric link — requirements.json ' + reqId + ' lists ' + modelFile + ' in formal_models but model-registry ' + modelFile + ' does not list ' + reqId + ' in requirements\n');
276
+ }
277
+ }
278
+ }
279
+
280
+ const totalChecked = checkedPairs.size;
281
+ const asymmetricCount = asymmetricLinks.length;
282
+ const staleCount = staleLinks.length;
283
+
284
+ return {
285
+ asymmetric_links: asymmetricLinks,
286
+ stale_links: staleLinks,
287
+ summary: {
288
+ total_checked: totalChecked,
289
+ asymmetric_count: asymmetricCount,
290
+ stale_count: staleCount,
291
+ clean: asymmetricCount === 0 && staleCount === 0,
292
+ },
293
+ };
294
+ }
295
+
296
+ // ── State-Space Analysis (DECOMP-04) ────────────────────────────────────────
297
+
298
+ /**
299
+ * Load state-space analysis by spawning bin/analyze-state-space.cjs.
300
+ * Returns the full report object, or {} on failure (fail-open).
301
+ */
302
+ function loadStateSpaceAnalysis() {
303
+ const analyzerPath = path.join(__dirname, 'analyze-state-space.cjs');
304
+ if (!fs.existsSync(analyzerPath)) {
305
+ process.stderr.write(TAG + ' warn: analyze-state-space.cjs not found — state_space section will be empty\n');
306
+ return {};
307
+ }
308
+ try {
309
+ const result = spawnSync(process.execPath, [analyzerPath, '--json', '--project-root=' + ROOT], {
310
+ encoding: 'utf8',
311
+ cwd: ROOT,
312
+ timeout: 30000,
313
+ });
314
+ if (result.status !== 0) {
315
+ process.stderr.write(TAG + ' warn: analyze-state-space.cjs exited ' + result.status + '\n');
316
+ return {};
317
+ }
318
+ return JSON.parse(result.stdout);
319
+ } catch (err) {
320
+ process.stderr.write(TAG + ' warn: analyze-state-space.cjs failed: ' + err.message + '\n');
321
+ return {};
322
+ }
323
+ }
324
+
325
+ // ── Matrix Construction ─────────────────────────────────────────────────────
326
+
327
+ function buildMatrix() {
328
+ // Load all data sources
329
+ const annotations = loadAnnotations();
330
+ const registry = loadRegistry();
331
+ const checkResults = loadCheckResults();
332
+ const requirements = loadRequirements();
333
+ const unitTestCoverage = loadUnitTestCoverage();
334
+
335
+ const checkMap = buildCheckResultMap(checkResults);
336
+
337
+ // Track which files had annotations vs fallback
338
+ const annotatedFiles = new Set(Object.keys(annotations));
339
+ let fallbackCount = 0;
340
+
341
+ // Bidirectional indexes
342
+ const requirementsIndex = {}; // reqId -> { id, properties: [...] }
343
+ const propertiesIndex = {}; // "file::property" -> { ... }
344
+
345
+ // Track all covered requirement IDs
346
+ const coveredReqIds = new Set();
347
+
348
+ // ── Process annotation-sourced properties ──
349
+
350
+ let totalAnnotationProps = 0;
351
+
352
+ for (const [modelFile, props] of Object.entries(annotations)) {
353
+ for (const { property, requirement_ids } of props) {
354
+ totalAnnotationProps++;
355
+ const key = modelFile + '::' + property;
356
+
357
+ // Find check result for this property (match by any shared requirement_id)
358
+ let latestResult = null;
359
+ let checkId = null;
360
+ for (const reqId of requirement_ids) {
361
+ const cr = checkMap.get(reqId);
362
+ if (cr) {
363
+ latestResult = cr.result;
364
+ checkId = cr.check_id;
365
+ break; // use first match
366
+ }
367
+ }
368
+
369
+ // Build property entry
370
+ const propEntry = {
371
+ model_file: modelFile,
372
+ property_name: property,
373
+ requirement_ids: requirement_ids,
374
+ source: 'annotation',
375
+ latest_result: latestResult,
376
+ check_id: checkId,
377
+ };
378
+ propertiesIndex[key] = propEntry;
379
+
380
+ // Add to requirements index
381
+ for (const reqId of requirement_ids) {
382
+ coveredReqIds.add(reqId);
383
+ if (!requirementsIndex[reqId]) {
384
+ requirementsIndex[reqId] = { id: reqId, properties: [] };
385
+ }
386
+ requirementsIndex[reqId].properties.push({
387
+ model_file: modelFile,
388
+ property_name: property,
389
+ source: 'annotation',
390
+ latest_result: latestResult,
391
+ check_id: checkId,
392
+ });
393
+ }
394
+
395
+ // Detect orphan
396
+ if (requirement_ids.length === 0) {
397
+ // Property with no requirements — will be counted as orphan
398
+ }
399
+ }
400
+ }
401
+
402
+ // ── Process model-registry fallback ──
403
+
404
+ const registryFiles = Object.keys(registry.models || {});
405
+
406
+ for (const modelFile of registryFiles) {
407
+ if (annotatedFiles.has(modelFile)) continue; // annotations exist — skip fallback
408
+
409
+ const entry = registry.models[modelFile];
410
+ const reqs = entry.requirements || [];
411
+ if (reqs.length === 0) continue; // no requirements in registry either
412
+
413
+ fallbackCount++;
414
+ process.stderr.write(TAG + ' warn: No annotations for ' + modelFile + ', using model-registry fallback\n');
415
+
416
+ const key = modelFile + '::(model-level)';
417
+
418
+ // Find check result by requirement overlap
419
+ let latestResult = null;
420
+ let checkId = null;
421
+ for (const reqId of reqs) {
422
+ const cr = checkMap.get(reqId);
423
+ if (cr) {
424
+ latestResult = cr.result;
425
+ checkId = cr.check_id;
426
+ break;
427
+ }
428
+ }
429
+
430
+ const propEntry = {
431
+ model_file: modelFile,
432
+ property_name: '(model-level)',
433
+ requirement_ids: reqs,
434
+ source: 'model-registry',
435
+ latest_result: latestResult,
436
+ check_id: checkId,
437
+ };
438
+ propertiesIndex[key] = propEntry;
439
+
440
+ for (const reqId of reqs) {
441
+ coveredReqIds.add(reqId);
442
+ if (!requirementsIndex[reqId]) {
443
+ requirementsIndex[reqId] = { id: reqId, properties: [] };
444
+ }
445
+ requirementsIndex[reqId].properties.push({
446
+ model_file: modelFile,
447
+ property_name: '(model-level)',
448
+ source: 'model-registry',
449
+ latest_result: latestResult,
450
+ check_id: checkId,
451
+ });
452
+ }
453
+ }
454
+
455
+ // ── Coverage Summary ──
456
+
457
+ const allReqIds = requirements.map(r => r.id);
458
+ const totalRequirements = allReqIds.length;
459
+ const coveredCount = allReqIds.filter(id => coveredReqIds.has(id)).length;
460
+ const coveragePercentage = totalRequirements > 0
461
+ ? Math.round((coveredCount / totalRequirements) * 1000) / 10
462
+ : 0;
463
+
464
+ const uncoveredRequirements = allReqIds
465
+ .filter(id => !coveredReqIds.has(id))
466
+ .sort();
467
+
468
+ // Orphan detection: properties with empty requirement_ids
469
+ const orphanProperties = [];
470
+ for (const [key, prop] of Object.entries(propertiesIndex)) {
471
+ if (!prop.requirement_ids || prop.requirement_ids.length === 0) {
472
+ orphanProperties.push(key);
473
+ }
474
+ }
475
+
476
+ // ── Bidirectional validation (TRACE-04) ──
477
+
478
+ const bidirectionalValidation = validateBidirectionalLinks(registry, requirements);
479
+
480
+ // ── State-space analysis (DECOMP-04) ──
481
+
482
+ const stateSpaceReport = loadStateSpaceAnalysis();
483
+ const stateSpaceSection = {};
484
+
485
+ if (stateSpaceReport.models) {
486
+ for (const [modelFile, analysis] of Object.entries(stateSpaceReport.models)) {
487
+ stateSpaceSection[modelFile] = {
488
+ risk_level: analysis.risk_level,
489
+ estimated_states: analysis.estimated_states,
490
+ has_unbounded: analysis.has_unbounded,
491
+ unbounded_domains: analysis.unbounded_domains || [],
492
+ risk_reason: analysis.risk_reason || null,
493
+ variable_count: (analysis.variables || []).length,
494
+ constant_count: (analysis.constants || []).length,
495
+ };
496
+ }
497
+ }
498
+
499
+ // ── Merge unit test coverage ──
500
+
501
+ let unitTestCoverageMerged = 0;
502
+ if (unitTestCoverage && unitTestCoverage.requirements) {
503
+ for (const [reqId, utcData] of Object.entries(unitTestCoverage.requirements)) {
504
+ if (requirementsIndex[reqId]) {
505
+ requirementsIndex[reqId].unit_test_coverage = {
506
+ covered: utcData.covered,
507
+ test_cases: utcData.test_cases || [],
508
+ };
509
+ unitTestCoverageMerged++;
510
+ }
511
+ }
512
+ }
513
+
514
+ // ── Build matrix ──
515
+
516
+ const matrix = {
517
+ metadata: {
518
+ generated_at: new Date().toISOString(),
519
+ generator_version: '1.1',
520
+ data_sources: {
521
+ annotations: {
522
+ file_count: annotatedFiles.size,
523
+ property_count: totalAnnotationProps,
524
+ },
525
+ model_registry: {
526
+ file_count: registryFiles.length,
527
+ used_as_fallback: fallbackCount,
528
+ },
529
+ check_results: {
530
+ entry_count: checkResults.length,
531
+ },
532
+ unit_test_coverage: {
533
+ available: unitTestCoverage !== null,
534
+ requirements_matched: unitTestCoverageMerged,
535
+ },
536
+ },
537
+ },
538
+ requirements: requirementsIndex,
539
+ properties: propertiesIndex,
540
+ coverage_summary: {
541
+ total_requirements: totalRequirements,
542
+ covered_count: coveredCount,
543
+ coverage_percentage: coveragePercentage,
544
+ uncovered_requirements: uncoveredRequirements,
545
+ orphan_properties: orphanProperties,
546
+ },
547
+ bidirectional_validation: bidirectionalValidation,
548
+ state_space: stateSpaceSection,
549
+ };
550
+
551
+ return matrix;
552
+ }
553
+
554
+ // ── Coverage Preservation (DECOMP-03) ───────────────────────────────────────
555
+
556
+ /**
557
+ * Compare the newly generated matrix against the previous traceability-matrix.json
558
+ * on disk to detect per-requirement coverage regressions.
559
+ *
560
+ * Must be called BEFORE the new matrix is written to disk so the baseline file
561
+ * is still the old version.
562
+ *
563
+ * @param {Object} newMatrix — the newly built matrix object
564
+ * @returns {{ baseline_found, baseline_date, requirements_checked, regressions, summary }}
565
+ */
566
+ function validateCoveragePreservation(newMatrix) {
567
+ // Attempt to load baseline (current file on disk)
568
+ let baseline = null;
569
+ try {
570
+ if (fs.existsSync(OUTPUT_PATH)) {
571
+ baseline = JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf8'));
572
+ }
573
+ } catch (err) {
574
+ process.stderr.write(TAG + ' warn: could not parse previous traceability-matrix.json — treating as no baseline: ' + err.message + '\n');
575
+ }
576
+
577
+ if (!baseline || !baseline.requirements) {
578
+ process.stderr.write(TAG + ' info: no previous traceability-matrix.json found — coverage preservation check skipped\n');
579
+ return {
580
+ baseline_found: false,
581
+ baseline_date: null,
582
+ requirements_checked: 0,
583
+ regressions: [],
584
+ summary: {
585
+ total_regressions: 0,
586
+ affected_requirements: 0,
587
+ clean: true,
588
+ },
589
+ };
590
+ }
591
+
592
+ const baselineDate = (baseline.metadata && baseline.metadata.generated_at) || null;
593
+ const regressions = [];
594
+ let requirementsChecked = 0;
595
+
596
+ // Compare per-requirement property counts
597
+ for (const reqId of Object.keys(baseline.requirements)) {
598
+ // Only check requirements that exist in both baseline and new matrix
599
+ if (!newMatrix.requirements || !newMatrix.requirements[reqId]) continue;
600
+
601
+ requirementsChecked++;
602
+
603
+ const baselineProps = baseline.requirements[reqId].properties || [];
604
+ const currentProps = newMatrix.requirements[reqId].properties || [];
605
+ const baselineCount = baselineProps.length;
606
+ const currentCount = currentProps.length;
607
+
608
+ if (currentCount < baselineCount) {
609
+ const lostCount = baselineCount - currentCount;
610
+ const baselineModels = [...new Set(baselineProps.map(function(p) { return p.model_file; }))];
611
+ const currentModels = [...new Set(currentProps.map(function(p) { return p.model_file; }))];
612
+
613
+ const regression = {
614
+ requirement_id: reqId,
615
+ baseline_property_count: baselineCount,
616
+ current_property_count: currentCount,
617
+ lost_count: lostCount,
618
+ baseline_models: baselineModels,
619
+ current_models: currentModels,
620
+ detail: reqId + ' coverage decreased from ' + baselineCount + ' properties to ' + currentCount + ' (lost ' + lostCount + ') — possible model split dropped coverage',
621
+ };
622
+ regressions.push(regression);
623
+ process.stderr.write(TAG + ' warn: coverage regression — ' + reqId + ' had ' + baselineCount + ' properties, now has ' + currentCount + ' (lost ' + lostCount + ') — check if model split dropped coverage\n');
624
+ }
625
+ }
626
+
627
+ return {
628
+ baseline_found: true,
629
+ baseline_date: baselineDate,
630
+ requirements_checked: requirementsChecked,
631
+ regressions: regressions,
632
+ summary: {
633
+ total_regressions: regressions.length,
634
+ affected_requirements: regressions.length,
635
+ clean: regressions.length === 0,
636
+ },
637
+ };
638
+ }
639
+
640
+ // ── Output ──────────────────────────────────────────────────────────────────
641
+
642
+ function main() {
643
+ const matrix = buildMatrix();
644
+
645
+ // Coverage preservation — compare against baseline BEFORE overwriting file
646
+ const coveragePreservation = validateCoveragePreservation(matrix);
647
+ matrix.coverage_preservation = coveragePreservation;
648
+
649
+ const jsonStr = JSON.stringify(matrix, null, 2);
650
+
651
+ if (jsonMode) {
652
+ process.stdout.write(jsonStr + '\n');
653
+ return;
654
+ }
655
+
656
+ // Write to file (overwrites previous baseline)
657
+ fs.writeFileSync(OUTPUT_PATH, jsonStr + '\n', 'utf8');
658
+
659
+ if (!quietMode) {
660
+ const cs = matrix.coverage_summary;
661
+ const ds = matrix.metadata.data_sources;
662
+ const matchedChecks = new Set();
663
+ for (const prop of Object.values(matrix.properties)) {
664
+ if (prop.check_id) matchedChecks.add(prop.check_id);
665
+ }
666
+
667
+ const bv = matrix.bidirectional_validation.summary;
668
+ const cp = matrix.coverage_preservation;
669
+ const utc = ds.unit_test_coverage;
670
+
671
+ process.stdout.write(TAG + ' Generated .planning/formal/traceability-matrix.json\n');
672
+ process.stdout.write(TAG + ' Requirements: ' + cs.covered_count + ' covered / ' + cs.total_requirements + ' total (' + cs.coverage_percentage + '%)\n');
673
+ process.stdout.write(TAG + ' Properties: ' + ds.annotations.property_count + ' (' + ds.annotations.file_count + ' files)\n');
674
+ process.stdout.write(TAG + ' Orphan properties: ' + cs.orphan_properties.length + '\n');
675
+ process.stdout.write(TAG + ' Check results matched: ' + matchedChecks.size + '\n');
676
+ if (utc.available) {
677
+ process.stdout.write(TAG + ' Unit test coverage: ' + utc.requirements_matched + ' requirements matched\n');
678
+ }
679
+ process.stdout.write(TAG + ' Bidirectional validation: ' + bv.asymmetric_count + ' asymmetric, ' + bv.stale_count + ' stale (' + bv.total_checked + ' pairs checked)\n');
680
+ if (cp.baseline_found) {
681
+ process.stdout.write(TAG + ' Coverage preservation: ' + cp.summary.total_regressions + ' regressions (' + cp.requirements_checked + ' requirements checked vs baseline)\n');
682
+ } else {
683
+ process.stdout.write(TAG + ' Coverage preservation: no baseline (first run)\n');
684
+ }
685
+
686
+ // State-space summary
687
+ const ssKeys = Object.keys(matrix.state_space || {});
688
+ if (ssKeys.length > 0) {
689
+ const counts = { HIGH: 0, MODERATE: 0, LOW: 0, MINIMAL: 0 };
690
+ for (const k of ssKeys) {
691
+ const level = matrix.state_space[k].risk_level;
692
+ if (counts[level] !== undefined) counts[level]++;
693
+ }
694
+ process.stdout.write(TAG + ' State-space: ' + ssKeys.length + ' models analyzed (' + counts.HIGH + ' HIGH, ' + counts.MODERATE + ' MODERATE, ' + counts.LOW + ' LOW, ' + counts.MINIMAL + ' MINIMAL)\n');
695
+ } else {
696
+ process.stdout.write(TAG + ' State-space: not available (analyze-state-space.cjs missing or failed)\n');
697
+ }
698
+ }
699
+ }
700
+
701
+ main();