@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,788 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/formal-test-sync.cjs
4
+ // Orchestrator: cross-references formal model invariants with unit test coverage.
5
+ // Validates formal constants against runtime config, generates test stubs, updates traceability matrix.
6
+ //
7
+ // Usage:
8
+ // node bin/formal-test-sync.cjs # full sync (coverage + constants + stubs + sidecar)
9
+ // node bin/formal-test-sync.cjs --report-only # read-only (no stub generation, no sidecar write)
10
+ // node bin/formal-test-sync.cjs --dry-run # show what would be generated (no writes)
11
+ // node bin/formal-test-sync.cjs --json # JSON output instead of human-readable
12
+ // node bin/formal-test-sync.cjs --stubs-dir=<path> # override default stubs directory
13
+ //
14
+ // Requirements: QUICK-139
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { spawnSync } = require('child_process');
19
+
20
+ const TAG = '[formal-test-sync]';
21
+ let ROOT = process.cwd();
22
+
23
+ // Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
24
+ for (const arg of process.argv.slice(2)) {
25
+ if (arg.startsWith('--project-root=')) {
26
+ ROOT = path.resolve(arg.slice('--project-root='.length));
27
+ }
28
+ }
29
+
30
+ const EXTRACT_ANNOTATIONS_SCRIPT = path.join(__dirname, 'extract-annotations.cjs');
31
+ const CONSTANTS_MAPPING_PATH = path.join(ROOT, '.planning', 'formal', 'constants-mapping.json');
32
+ const REQUIREMENTS_PATH = path.join(ROOT, '.planning', 'formal', 'requirements.json');
33
+ const CONFIG_LOADER_PATH = path.join(ROOT, 'hooks', 'config-loader.js');
34
+ const REPORT_OUTPUT_PATH = path.join(ROOT, '.planning', 'formal', 'formal-test-sync-report.json');
35
+ const SIDECAR_OUTPUT_PATH = path.join(ROOT, '.planning', 'formal', 'unit-test-coverage.json');
36
+
37
+ // ── CLI flags ────────────────────────────────────────────────────────────────
38
+
39
+ const args = process.argv.slice(2);
40
+ const reportOnly = args.includes('--report-only');
41
+ const dryRun = args.includes('--dry-run');
42
+ const jsonMode = args.includes('--json');
43
+ let stubsDir = path.join(ROOT, '.planning', 'formal', 'generated-stubs');
44
+
45
+ for (const arg of args) {
46
+ if (arg.startsWith('--stubs-dir=')) {
47
+ stubsDir = arg.slice('--stubs-dir='.length);
48
+ }
49
+ }
50
+
51
+ // ── Data Loading ─────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Load formal annotations from extract-annotations.cjs (without test files).
55
+ * Returns { model_file: [{ property, requirement_ids }] } or {} on failure.
56
+ */
57
+ function loadFormalAnnotations() {
58
+ try {
59
+ const result = spawnSync(process.execPath, [EXTRACT_ANNOTATIONS_SCRIPT, '--project-root=' + ROOT], {
60
+ encoding: 'utf8',
61
+ cwd: ROOT,
62
+ timeout: 30000,
63
+ });
64
+ if (result.status !== 0) {
65
+ process.stderr.write(TAG + ' warn: extract-annotations.cjs failed\n');
66
+ return {};
67
+ }
68
+ return JSON.parse(result.stdout);
69
+ } catch (err) {
70
+ process.stderr.write(TAG + ' warn: extract-annotations.cjs error: ' + err.message + '\n');
71
+ return {};
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Load test annotations from extract-annotations.cjs with --include-tests.
77
+ * Filters to only test: prefixed keys and strips the prefix.
78
+ * Returns { "hooks/config-loader.test.js": [{ test_name, requirement_ids }], ... }
79
+ */
80
+ function loadTestAnnotations() {
81
+ try {
82
+ const result = spawnSync(process.execPath, [EXTRACT_ANNOTATIONS_SCRIPT, '--include-tests', '--project-root=' + ROOT], {
83
+ encoding: 'utf8',
84
+ cwd: ROOT,
85
+ timeout: 30000,
86
+ });
87
+ if (result.status !== 0) {
88
+ process.stderr.write(TAG + ' warn: extract-annotations --include-tests failed\n');
89
+ return {};
90
+ }
91
+ const allAnnotations = JSON.parse(result.stdout);
92
+ const testAnnotations = {};
93
+ for (const [key, value] of Object.entries(allAnnotations)) {
94
+ if (key.startsWith('test:')) {
95
+ testAnnotations[key.slice('test:'.length)] = value;
96
+ }
97
+ }
98
+ return testAnnotations;
99
+ } catch (err) {
100
+ process.stderr.write(TAG + ' warn: extract-annotations --include-tests error: ' + err.message + '\n');
101
+ return {};
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Load requirements from .planning/formal/requirements.json.
107
+ * Returns array of { id, ... } or [] on failure (fail-open).
108
+ */
109
+ function loadRequirements() {
110
+ if (!fs.existsSync(REQUIREMENTS_PATH)) {
111
+ process.stderr.write(TAG + ' warn: requirements.json not found\n');
112
+ return [];
113
+ }
114
+ try {
115
+ const data = JSON.parse(fs.readFileSync(REQUIREMENTS_PATH, 'utf8'));
116
+ return data.requirements || [];
117
+ } catch (err) {
118
+ process.stderr.write(TAG + ' warn: requirements.json parse error: ' + err.message + '\n');
119
+ return [];
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Load constants mapping from .planning/formal/constants-mapping.json.
125
+ * Returns array of { constant, source, config_path, formal_value, ... } or [] on failure.
126
+ */
127
+ function loadConstantsMapping() {
128
+ if (!fs.existsSync(CONSTANTS_MAPPING_PATH)) {
129
+ process.stderr.write(TAG + ' warn: constants-mapping.json not found\n');
130
+ return [];
131
+ }
132
+ try {
133
+ const data = JSON.parse(fs.readFileSync(CONSTANTS_MAPPING_PATH, 'utf8'));
134
+ return data.mappings || [];
135
+ } catch (err) {
136
+ process.stderr.write(TAG + ' warn: constants-mapping.json parse error: ' + err.message + '\n');
137
+ return [];
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Load DEFAULT_CONFIG from hooks/config-loader.js.
143
+ * Returns the config object or {} on failure.
144
+ */
145
+ function loadDefaultConfig() {
146
+ try {
147
+ const { DEFAULT_CONFIG } = require(CONFIG_LOADER_PATH);
148
+ return DEFAULT_CONFIG || {};
149
+ } catch (err) {
150
+ process.stderr.write(TAG + ' warn: could not load DEFAULT_CONFIG: ' + err.message + '\n');
151
+ return {};
152
+ }
153
+ }
154
+
155
+ // ── Constants Parsing ────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Parse TLA+ .cfg file and extract CONSTANTS block values.
159
+ * Pattern: key = value pairs after CONSTANTS keyword until next section keyword.
160
+ */
161
+ function parseTLACfgConstants(content) {
162
+ const lines = content.split('\n');
163
+ const result = {};
164
+ let inConstants = false;
165
+
166
+ for (const line of lines) {
167
+ const trimmed = line.trim();
168
+
169
+ // Detect CONSTANTS section start
170
+ if (/^CONSTANTS\s*$/.test(trimmed)) {
171
+ inConstants = true;
172
+ continue;
173
+ }
174
+
175
+ // Detect section keywords that end CONSTANTS block
176
+ if (inConstants && /^(SPECIFICATION|INVARIANT|PROPERTY|SYMMETRY|CHECK_DEADLOCK)\s*/.test(trimmed)) {
177
+ inConstants = false;
178
+ continue;
179
+ }
180
+
181
+ if (inConstants && trimmed.length > 0 && !trimmed.startsWith('\\')) {
182
+ // Match key = value pattern
183
+ const match = trimmed.match(/^\s*(\w+)\s*=\s*(.+)$/);
184
+ if (match) {
185
+ const key = match[1];
186
+ const value = match[2].trim();
187
+ // Try to parse as number
188
+ if (/^\d+$/.test(value)) {
189
+ result[key] = parseInt(value, 10);
190
+ } else {
191
+ result[key] = value;
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * Parse Alloy file and extract Defaults sig field values.
202
+ * Pattern: one sig Defaults { ... } { ... }
203
+ */
204
+ function parseAlloyDefaults(content) {
205
+ const result = {};
206
+ // Find Defaults sig constraint block: { defaultX = value, ... }
207
+ const match = content.match(/one\s+sig\s+Defaults\s*\{[^}]*\}\s*\{([^}]*)\}/);
208
+ if (!match) return result;
209
+
210
+ const constraintBlock = match[1];
211
+ // Parse field = value pairs (split on newlines, not commas)
212
+ const pairs = constraintBlock.split('\n')
213
+ .filter(line => {
214
+ const t = line.trim();
215
+ return t.length > 0 && !t.startsWith('--') && !t.startsWith('//');
216
+ });
217
+ for (const pair of pairs) {
218
+ const trimmed = pair.trim();
219
+ const kv = trimmed.match(/^\s*(\w+)\s*=\s*(\S+)\s*$/);
220
+ if (kv) {
221
+ const key = kv[1];
222
+ const val = kv[2].trim();
223
+ // Try to parse as number
224
+ if (/^\d+$/.test(val)) {
225
+ result[key] = parseInt(val, 10);
226
+ } else {
227
+ result[key] = val;
228
+ }
229
+ }
230
+ }
231
+
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Resolve dot-notation path (e.g., "circuit_breaker.oscillation_depth") against a config object.
237
+ */
238
+ function resolveConfigPath(dotPath, config) {
239
+ const parts = dotPath.split('.');
240
+ let current = config;
241
+ for (const part of parts) {
242
+ if (current === null || current === undefined || typeof current !== 'object') {
243
+ return undefined;
244
+ }
245
+ current = current[part];
246
+ }
247
+ return current;
248
+ }
249
+
250
+ /**
251
+ * Validate formal constants against config defaults.
252
+ * Returns array of { constant, source, formal_value, config_value, config_path, match, intentional_divergence }.
253
+ */
254
+ function validateConstants(mappings) {
255
+ const config = loadDefaultConfig();
256
+ const results = [];
257
+
258
+ for (const mapping of mappings) {
259
+ const { constant, source, config_path, formal_value, transform, intentional_divergence } = mapping;
260
+
261
+ // Skip model-only constants
262
+ if (config_path === null) {
263
+ results.push({
264
+ constant,
265
+ source,
266
+ formal_value,
267
+ config_value: null,
268
+ config_path: null,
269
+ match: null, // N/A
270
+ intentional_divergence: false,
271
+ detail: 'model-only, no runtime config',
272
+ });
273
+ continue;
274
+ }
275
+
276
+ // Read source file and parse constants
277
+ const sourcePath = path.join(ROOT, source);
278
+ if (!fs.existsSync(sourcePath)) {
279
+ results.push({
280
+ constant,
281
+ source,
282
+ formal_value,
283
+ config_value: null,
284
+ config_path,
285
+ match: false,
286
+ intentional_divergence: intentional_divergence || false,
287
+ detail: 'source file not found',
288
+ });
289
+ continue;
290
+ }
291
+
292
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
293
+ let parsedConstants;
294
+ if (source.endsWith('.cfg')) {
295
+ parsedConstants = parseTLACfgConstants(sourceContent);
296
+ } else if (source.endsWith('.als')) {
297
+ parsedConstants = parseAlloyDefaults(sourceContent);
298
+ } else {
299
+ results.push({
300
+ constant,
301
+ source,
302
+ formal_value,
303
+ config_value: null,
304
+ config_path,
305
+ match: false,
306
+ intentional_divergence: intentional_divergence || false,
307
+ detail: 'unsupported source file type',
308
+ });
309
+ continue;
310
+ }
311
+
312
+ // Look up the constant in parsed values
313
+ const parsedValue = parsedConstants[constant];
314
+ if (parsedValue === undefined) {
315
+ results.push({
316
+ constant,
317
+ source,
318
+ formal_value,
319
+ config_value: null,
320
+ config_path,
321
+ match: false,
322
+ intentional_divergence: intentional_divergence || false,
323
+ detail: 'constant not found in source file',
324
+ });
325
+ continue;
326
+ }
327
+
328
+ // Resolve config path to get runtime value
329
+ let configValue = resolveConfigPath(config_path, config);
330
+
331
+ // Apply transform if specified
332
+ let transformedFormalValue = formal_value;
333
+ if (transform) {
334
+ const [from, to] = transform.split(' -> ').map(s => s.trim());
335
+ if (from === String(formal_value)) {
336
+ transformedFormalValue = JSON.parse(to);
337
+ }
338
+ }
339
+
340
+ const match = transformedFormalValue === configValue;
341
+
342
+ results.push({
343
+ constant,
344
+ source,
345
+ formal_value,
346
+ config_value: configValue,
347
+ config_path,
348
+ match,
349
+ intentional_divergence: intentional_divergence || false,
350
+ });
351
+ }
352
+
353
+ return results;
354
+ }
355
+
356
+ // ── Coverage Analysis ────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Build a coverage report: for each requirement, track formal and test coverage.
360
+ * Returns { covered: [...], uncovered: [...], gaps: [...], stats: { ... } }.
361
+ */
362
+ function buildCoverageReport(formalAnnotations, testAnnotations, requirements) {
363
+ // Build requirement maps
364
+ const formalCoverageMap = new Map(); // reqId -> Set of { model_file, property }
365
+ const testCoverageMap = new Map(); // reqId -> Set of { test_file, test_name }
366
+
367
+ // Process formal annotations
368
+ for (const [modelFile, props] of Object.entries(formalAnnotations)) {
369
+ for (const { property, requirement_ids } of props) {
370
+ for (const reqId of requirement_ids) {
371
+ if (!formalCoverageMap.has(reqId)) {
372
+ formalCoverageMap.set(reqId, []);
373
+ }
374
+ formalCoverageMap.get(reqId).push({ 'model_file': modelFile, property });
375
+ }
376
+ }
377
+ }
378
+
379
+ // Process test annotations
380
+ for (const [testFile, tests] of Object.entries(testAnnotations)) {
381
+ for (const { test_name, requirement_ids } of tests) {
382
+ for (const reqId of requirement_ids) {
383
+ if (!testCoverageMap.has(reqId)) {
384
+ testCoverageMap.set(reqId, []);
385
+ }
386
+ testCoverageMap.get(reqId).push({ 'test_file': testFile, test_name });
387
+ }
388
+ }
389
+ }
390
+
391
+ const covered = [];
392
+ const uncovered = [];
393
+ const gaps = []; // formal coverage but no test
394
+
395
+ for (const req of requirements) {
396
+ const reqId = req.id;
397
+ const hasFormal = formalCoverageMap.has(reqId);
398
+ const hasTest = testCoverageMap.has(reqId);
399
+
400
+ const entry = {
401
+ requirement_id: reqId,
402
+ has_formal: hasFormal,
403
+ has_test: hasTest,
404
+ formal_properties: hasFormal ? formalCoverageMap.get(reqId) : [],
405
+ test_cases: hasTest ? testCoverageMap.get(reqId) : [],
406
+ };
407
+
408
+ if (hasFormal && hasTest) {
409
+ covered.push(entry);
410
+ } else if (!hasFormal && !hasTest) {
411
+ uncovered.push(entry);
412
+ } else if (hasFormal && !hasTest) {
413
+ entry.gap = true;
414
+ gaps.push(entry);
415
+ }
416
+ }
417
+
418
+ const totalReqs = requirements.length;
419
+ const formalCovered = formalCoverageMap.size;
420
+ const testCovered = testCoverageMap.size;
421
+ const bothCovered = covered.length;
422
+ const gapCount = gaps.length;
423
+
424
+ return {
425
+ covered,
426
+ uncovered,
427
+ gaps,
428
+ stats: {
429
+ total: totalReqs,
430
+ formal_covered: formalCovered,
431
+ test_covered: testCovered,
432
+ both_covered: bothCovered,
433
+ gap_count: gapCount,
434
+ },
435
+ };
436
+ }
437
+
438
+ // ── Recipe Helpers ────────────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * Extract property/invariant definition text from a formal model file.
442
+ * Supports TLA+ (.tla), Alloy (.als), and PRISM (.prism/.sm).
443
+ * Returns the extracted text string, or '' on failure (fail-open).
444
+ */
445
+ function extractPropertyDefinition(modelFile, propertyName) {
446
+ try {
447
+ if (!fs.existsSync(modelFile)) return '';
448
+ const content = fs.readFileSync(modelFile, 'utf8');
449
+
450
+ if (modelFile.endsWith('.tla')) {
451
+ // TLA+: propertyName == <body> until blank line or next definition
452
+ const escaped = propertyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
453
+ const re = new RegExp('^' + escaped + '\\s*==[\\s\\S]*?(?=\\n\\s*\\n|\\n\\w+\\s*==|$)', 'm');
454
+ const m = content.match(re);
455
+ return m ? m[0].trim() : '';
456
+ }
457
+
458
+ if (modelFile.endsWith('.als')) {
459
+ // Alloy: pred propertyName { ... } or assert propertyName { ... }
460
+ const escaped = propertyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
461
+ const re = new RegExp('(?:pred|assert|fact)\\s+' + escaped + '\\s*\\{', 'm');
462
+ const m = content.match(re);
463
+ if (!m) return '';
464
+ // Find matching closing brace
465
+ const start = m.index;
466
+ let depth = 0;
467
+ let end = start;
468
+ for (let i = start; i < content.length; i++) {
469
+ if (content[i] === '{') depth++;
470
+ if (content[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
471
+ }
472
+ return content.slice(start, end).trim();
473
+ }
474
+
475
+ if (modelFile.endsWith('.prism') || modelFile.endsWith('.sm')) {
476
+ // PRISM: look for @requirement comment near P=? or filter lines
477
+ const lines = content.split('\n');
478
+ for (let i = 0; i < lines.length; i++) {
479
+ if (lines[i].includes(propertyName) || lines[i].includes('@requirement')) {
480
+ // Capture the property line and surrounding context
481
+ const start = Math.max(0, i - 1);
482
+ const end = Math.min(lines.length, i + 2);
483
+ return lines.slice(start, end).join('\n').trim();
484
+ }
485
+ }
486
+ }
487
+
488
+ return '';
489
+ } catch (_err) {
490
+ return '';
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Find source files implementing a requirement by grepping for the requirement ID.
496
+ * Falls back to key terms from requirement text if no direct matches.
497
+ * Returns array of relative file paths, or [] on failure (fail-open).
498
+ */
499
+ function findSourceFiles(requirementId, requirementText) {
500
+ try {
501
+ const result = spawnSync('grep', [
502
+ '-rl', requirementId,
503
+ 'bin/', 'hooks/', 'commands/',
504
+ '--include=*.cjs', '--include=*.mjs', '--include=*.js', '--include=*.md',
505
+ ], { encoding: 'utf8', cwd: ROOT, timeout: 10000 });
506
+
507
+ let files = (result.stdout || '').split('\n').filter(Boolean);
508
+ // Filter out formal model files and test files
509
+ files = files.filter(f =>
510
+ !f.endsWith('.tla') && !f.endsWith('.als') && !f.endsWith('.prism') &&
511
+ !f.endsWith('.sm') && !f.endsWith('.cfg') &&
512
+ !f.includes('.stub.test.js') && !f.includes('.test.js') && !f.includes('.test.cjs')
513
+ );
514
+
515
+ if (files.length === 0 && requirementText) {
516
+ // Fallback: try key terms (first 3 significant words >4 chars)
517
+ const words = requirementText.toLowerCase()
518
+ .replace(/[^a-z0-9\s]/g, '')
519
+ .split(/\s+/)
520
+ .filter(w => w.length > 4);
521
+ const keyTerms = words.slice(0, 3);
522
+ for (const term of keyTerms) {
523
+ const fallback = spawnSync('grep', [
524
+ '-rl', term,
525
+ 'bin/', 'hooks/',
526
+ '--include=*.cjs', '--include=*.mjs', '--include=*.js',
527
+ ], { encoding: 'utf8', cwd: ROOT, timeout: 10000 });
528
+ const found = (fallback.stdout || '').split('\n').filter(Boolean);
529
+ files.push(...found);
530
+ if (files.length > 0) break;
531
+ }
532
+ // Deduplicate
533
+ files = [...new Set(files)];
534
+ }
535
+
536
+ return files;
537
+ } catch (_err) {
538
+ return [];
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Classify test strategy based on requirement text keywords.
544
+ * Returns 'structural', 'behavioral', or 'constant'.
545
+ */
546
+ function classifyTestStrategy(requirementText) {
547
+ const text = (requirementText || '').toLowerCase();
548
+ if (/(?:returns|outputs|produces|result|calculates)/.test(text)) return 'behavioral';
549
+ if (/(?:constant|value|equals|match|threshold)/.test(text)) return 'constant';
550
+ // Default and explicit structural keywords
551
+ return 'structural';
552
+ }
553
+
554
+ /**
555
+ * Classify test template based on test strategy, source files, and requirement text.
556
+ * Returns one of: 'source-grep', 'import-and-call', 'config-validate'.
557
+ * Also generates pre-filled boilerplate with placeholder tokens.
558
+ */
559
+ function classifyTestTemplate(testStrategy, sourceFiles, requirementText) {
560
+ if (testStrategy === 'behavioral') {
561
+ return {
562
+ template: 'import-and-call',
563
+ boilerplate: "const mod = require(SOURCE);\nconst result = mod.FUNCTION(INPUT);\nassert.strictEqual(result, EXPECTED);",
564
+ };
565
+ }
566
+ if (testStrategy === 'constant') {
567
+ return {
568
+ template: 'config-validate',
569
+ boilerplate: "const { DEFAULT_CONFIG } = require(CONFIG_PATH);\nassert.strictEqual(resolveConfigPath(PATH, DEFAULT_CONFIG), EXPECTED);",
570
+ };
571
+ }
572
+ // Default: source-grep when testStrategy is undefined/null (quorum R3.6 hardening)
573
+ return {
574
+ template: 'source-grep',
575
+ boilerplate: "const content = fs.readFileSync(SOURCE, 'utf8');\nassert.match(content, /PATTERN/);",
576
+ };
577
+ }
578
+
579
+ // ── Stub Generation ──────────────────────────────────────────────────────────
580
+
581
+ /**
582
+ * Generate test stub files for uncovered invariants (gaps).
583
+ * Each stub is a skeleton test file with a TODO comment and assert.fail().
584
+ * Also generates .stub.recipe.json sidecars with pre-resolved context.
585
+ */
586
+ function generateStubs(gaps, formalAnnotations, requirements) {
587
+ const requirementMap = new Map(requirements.map(r => [r.id, r]));
588
+ const stubs = [];
589
+
590
+ for (const gap of gaps) {
591
+ const { requirement_id, formal_properties } = gap;
592
+ const property = formal_properties.length > 0 ? formal_properties[0].property : 'unknown';
593
+
594
+ const stubFileName = requirement_id + '.stub.test.js';
595
+ const stubFilePath = path.join(stubsDir, stubFileName);
596
+
597
+ if (!dryRun) {
598
+ // Create stubs directory if it doesn't exist
599
+ if (!fs.existsSync(stubsDir)) {
600
+ fs.mkdirSync(stubsDir, { recursive: true });
601
+ }
602
+
603
+ // Write stub file
604
+ const stubContent = `#!/usr/bin/env node
605
+ // @requirement ${requirement_id}
606
+ // Auto-generated stub for uncovered invariant: ${property}
607
+
608
+ const { test } = require('node:test');
609
+ const assert = require('node:assert/strict');
610
+
611
+ test('TODO: implement test for ${requirement_id} — ${property}', () => {
612
+ assert.fail('TODO: implement test for ${requirement_id} — ${property}');
613
+ });
614
+ `;
615
+ fs.writeFileSync(stubFilePath, stubContent, 'utf8');
616
+
617
+ // Write recipe sidecar with pre-resolved context
618
+ const recipeFileName = requirement_id + '.stub.recipe.json';
619
+ const recipeFilePath = path.join(stubsDir, recipeFileName);
620
+ const req = requirementMap.get(requirement_id);
621
+ // NOTE: Only the first formal_property is used for the recipe. Gaps with multiple
622
+ // formal_properties are intentionally reduced to the first entry — this is acceptable
623
+ // because most gaps have a single property, and the recipe is a hint, not exhaustive.
624
+ const modelFile = gap.formal_properties.length > 0 ? gap.formal_properties[0].model_file : '';
625
+ const definition = modelFile ? extractPropertyDefinition(path.join(ROOT, modelFile), property) : '';
626
+ const sourceFiles = findSourceFiles(requirement_id, req ? req.text : '');
627
+ const importHint = sourceFiles.length > 0
628
+ ? "const mod = require('" + path.resolve(ROOT, sourceFiles[0]) + "');"
629
+ : '';
630
+ const testStrategy = classifyTestStrategy(req ? req.text : '');
631
+ const templateClassification = classifyTestTemplate(testStrategy, sourceFiles, req ? req.text : '');
632
+
633
+ const recipe = {
634
+ requirement_id,
635
+ requirement_text: req ? req.text : '',
636
+ formal_property: {
637
+ name: property,
638
+ model_file: modelFile,
639
+ definition,
640
+ type: modelFile.endsWith('.tla') ? 'invariant' : modelFile.endsWith('.als') ? 'assertion' : 'property',
641
+ },
642
+ source_files: sourceFiles,
643
+ source_file_absolute: sourceFiles.length > 0 ? path.resolve(ROOT, sourceFiles[0]) : '',
644
+ source_files_absolute: sourceFiles.map(f => path.resolve(ROOT, f)),
645
+ import_hint: importHint,
646
+ test_strategy: testStrategy,
647
+ template: templateClassification.template,
648
+ template_boilerplate: templateClassification.boilerplate,
649
+ };
650
+
651
+ fs.writeFileSync(recipeFilePath, JSON.stringify(recipe, null, 2) + '\n', 'utf8');
652
+ }
653
+
654
+ stubs.push({
655
+ requirement_id,
656
+ stub_file: stubFilePath,
657
+ recipe_file: path.join(stubsDir, requirement_id + '.stub.recipe.json'),
658
+ property,
659
+ });
660
+ }
661
+
662
+ return stubs;
663
+ }
664
+
665
+ // ── Output Writers ───────────────────────────────────────────────────────────
666
+
667
+ /**
668
+ * Write formal-test-sync report to .planning/formal/formal-test-sync-report.json.
669
+ */
670
+ function writeReport(coverageReport, constantsValidation) {
671
+ const report = {
672
+ generated_at: new Date().toISOString(),
673
+ coverage_gaps: coverageReport,
674
+ constants_validation: constantsValidation,
675
+ };
676
+
677
+ fs.writeFileSync(REPORT_OUTPUT_PATH, JSON.stringify(report, null, 2) + '\n', 'utf8');
678
+ }
679
+
680
+ /**
681
+ * Write unit-test-coverage sidecar to .planning/formal/unit-test-coverage.json.
682
+ * Consumed by traceability matrix generator.
683
+ */
684
+ function writeSidecar(coverageReport) {
685
+ const requirements = {};
686
+
687
+ // Populate from covered requirements
688
+ for (const req of coverageReport.covered) {
689
+ requirements[req.requirement_id] = {
690
+ covered: true,
691
+ test_cases: req.test_cases,
692
+ };
693
+ }
694
+
695
+ // Add gap requirements (formal but no test)
696
+ for (const req of coverageReport.gaps) {
697
+ requirements[req.requirement_id] = {
698
+ covered: false,
699
+ test_cases: req.test_cases,
700
+ };
701
+ }
702
+
703
+ // Add uncovered requirements (no formal, no test)
704
+ for (const req of coverageReport.uncovered) {
705
+ requirements[req.requirement_id] = {
706
+ covered: false,
707
+ test_cases: req.test_cases,
708
+ };
709
+ }
710
+
711
+ const sidecar = {
712
+ generated_at: new Date().toISOString(),
713
+ requirements,
714
+ };
715
+
716
+ fs.writeFileSync(SIDECAR_OUTPUT_PATH, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
717
+ }
718
+
719
+ /**
720
+ * Print human-readable summary to stdout.
721
+ */
722
+ function printSummary(coverageReport, constantsValidation, stubs) {
723
+ const TAG_SUMMARY = '[formal-test-sync]';
724
+
725
+ process.stdout.write(TAG_SUMMARY + ' Generated formal-test-sync report\n');
726
+ process.stdout.write(TAG_SUMMARY + ' Coverage gaps: ' + coverageReport.stats.gap_count + ' requirements with formal but no test\n');
727
+ process.stdout.write(TAG_SUMMARY + ' Both covered: ' + coverageReport.stats.both_covered + ' requirements with formal AND test\n');
728
+ process.stdout.write(TAG_SUMMARY + ' Formal covered: ' + coverageReport.stats.formal_covered + ' / ' + coverageReport.stats.total + '\n');
729
+ process.stdout.write(TAG_SUMMARY + ' Test covered: ' + coverageReport.stats.test_covered + ' / ' + coverageReport.stats.total + '\n');
730
+
731
+ // Constants validation summary
732
+ const mismatches = constantsValidation.filter(c => !c.match && c.config_path !== null);
733
+ const mismatched = mismatches.filter(c => !c.intentional_divergence);
734
+ const intentional = mismatches.filter(c => c.intentional_divergence);
735
+
736
+ process.stdout.write(TAG_SUMMARY + ' Constants mismatches: ' + mismatched.length + ' (unexpected), ' + intentional.length + ' (intentional)\n');
737
+
738
+ if (stubs.length > 0) {
739
+ process.stdout.write(TAG_SUMMARY + ' Test stubs generated: ' + stubs.length + ' in ' + stubsDir + '\n');
740
+ const recipeCount = stubs.filter(s => s.recipe_file).length;
741
+ process.stdout.write(TAG_SUMMARY + ' Recipes: ' + recipeCount + ' generated\n');
742
+ }
743
+
744
+ if (!reportOnly) {
745
+ process.stdout.write(TAG_SUMMARY + ' Report: ' + REPORT_OUTPUT_PATH + '\n');
746
+ process.stdout.write(TAG_SUMMARY + ' Sidecar: ' + SIDECAR_OUTPUT_PATH + '\n');
747
+ }
748
+ }
749
+
750
+ // ── Main Flow ────────────────────────────────────────────────────────────────
751
+
752
+ function main() {
753
+ const formalAnnotations = loadFormalAnnotations();
754
+ const testAnnotations = loadTestAnnotations();
755
+ const requirements = loadRequirements();
756
+ const mappings = loadConstantsMapping();
757
+
758
+ const coverageReport = buildCoverageReport(formalAnnotations, testAnnotations, requirements);
759
+ const constantsValidation = validateConstants(mappings);
760
+
761
+ let stubs = [];
762
+ if (!reportOnly) {
763
+ stubs = generateStubs(coverageReport.gaps, formalAnnotations, requirements);
764
+ writeSidecar(coverageReport);
765
+ writeReport(coverageReport, constantsValidation);
766
+ }
767
+
768
+ if (jsonMode) {
769
+ const output = {
770
+ coverage_gaps: coverageReport,
771
+ constants_validation: constantsValidation,
772
+ stubs: stubs,
773
+ };
774
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
775
+ } else {
776
+ printSummary(coverageReport, constantsValidation, stubs);
777
+ }
778
+ }
779
+
780
+ // ── Exports (for testing) ────────────────────────────────────────────────────
781
+
782
+ module.exports = { parseAlloyDefaults, extractPropertyDefinition, findSourceFiles, classifyTestStrategy, classifyTestTemplate };
783
+
784
+ // ── Entry point ──────────────────────────────────────────────────────────────
785
+
786
+ if (require.main === module) {
787
+ main();
788
+ }