@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,633 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/extract-annotations.cjs
4
+ // Extracts @requirement annotations from formal model files (TLA+, Alloy, PRISM).
5
+ // Produces a JSON map: { model_file: [{ property, requirement_ids }] }
6
+ //
7
+ // Usage:
8
+ // node bin/extract-annotations.cjs # JSON output to stdout
9
+ // node bin/extract-annotations.cjs --pretty # pretty-printed JSON
10
+ // node bin/extract-annotations.cjs --summary # summary counts per file
11
+ // node bin/extract-annotations.cjs --validate # check for unannotated properties
12
+ //
13
+ // Requirements: ANNOT-04
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // ── Configuration ────────────────────────────────────────────────────────────
19
+
20
+ let ROOT = process.cwd();
21
+
22
+ // Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
23
+ for (const arg of process.argv.slice(2)) {
24
+ if (arg.startsWith('--project-root=')) {
25
+ ROOT = path.resolve(arg.slice('--project-root='.length));
26
+ }
27
+ }
28
+
29
+ const REGISTRY_PATH = path.join(ROOT, '.planning', 'formal', 'model-registry.json');
30
+
31
+ // ── Parsing Logic ────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Parse @requirement annotations from a TLA+ file.
35
+ * Pattern: \* @requirement REQ-ID
36
+ * Property: Name == (identifier at start of line followed by ==)
37
+ */
38
+ function parseTLA(content) {
39
+ const lines = content.split('\n');
40
+ const results = [];
41
+ let pendingReqs = [];
42
+
43
+ for (const line of lines) {
44
+ // Match annotation: \* @requirement REQ-ID
45
+ const annMatch = line.match(/^\s*\\\*\s*@requirement\s+([\w-]+)/);
46
+ if (annMatch) {
47
+ pendingReqs.push(annMatch[1]);
48
+ continue;
49
+ }
50
+
51
+ // Match property/action definition: PropertyName == or ActionName(params) ==
52
+ const propMatch = line.match(/^(\w+)\s*(?:\([^)]*\)\s*)?==/);
53
+ if (propMatch && pendingReqs.length > 0) {
54
+ results.push({
55
+ property: propMatch[1],
56
+ requirement_ids: [...pendingReqs]
57
+ });
58
+ pendingReqs = [];
59
+ continue;
60
+ }
61
+
62
+ // Non-annotation, non-property line: reset pending only if it's not
63
+ // a comment line (allow comment blocks between annotations and properties)
64
+ if (propMatch && pendingReqs.length === 0) {
65
+ // Property without annotation — tracked for validation
66
+ continue;
67
+ }
68
+
69
+ // Reset pending annotations if we hit a non-comment, non-blank line
70
+ // that isn't a property definition (orphan annotations)
71
+ const isComment = /^\s*\\\*/.test(line) || /^\s*\(\*/.test(line) || /^\s*\*/.test(line) || /^\s*\*\)/.test(line);
72
+ const isBlank = /^\s*$/.test(line);
73
+ if (!isComment && !isBlank && !annMatch && !propMatch) {
74
+ pendingReqs = [];
75
+ }
76
+ }
77
+
78
+ return results;
79
+ }
80
+
81
+ /**
82
+ * Parse @requirement annotations from an Alloy file.
83
+ * Pattern: -- @requirement REQ-ID
84
+ * Property: assert AssertName { or assert AssertName (on its own line)
85
+ */
86
+ function parseAlloy(content) {
87
+ const lines = content.split('\n');
88
+ const results = [];
89
+ let pendingReqs = [];
90
+
91
+ for (const line of lines) {
92
+ // Match annotation: -- @requirement REQ-ID
93
+ const annMatch = line.match(/^--\s*@requirement\s+([\w-]+)/);
94
+ if (annMatch) {
95
+ pendingReqs.push(annMatch[1]);
96
+ continue;
97
+ }
98
+
99
+ // Match Alloy construct: assert, fact, pred, fun, sig, one sig, lone sig, abstract sig, check
100
+ const constructMatch = line.match(/^(?:assert|fact|pred|fun|check|(?:one|lone|abstract)\s+sig|sig)\s+(\w+)/);
101
+ if (constructMatch && pendingReqs.length > 0) {
102
+ results.push({
103
+ property: constructMatch[1],
104
+ requirement_ids: [...pendingReqs]
105
+ });
106
+ pendingReqs = [];
107
+ continue;
108
+ }
109
+ }
110
+
111
+ return results;
112
+ }
113
+
114
+ /**
115
+ * Parse @requirement annotations from a PRISM .props file.
116
+ * Pattern: // @requirement REQ-ID
117
+ * Property: lines starting with P=?, R{, S=? or other PRISM property keywords
118
+ */
119
+ function parsePRISM(content) {
120
+ const lines = content.split('\n');
121
+ const results = [];
122
+ let pendingReqs = [];
123
+
124
+ for (const line of lines) {
125
+ const trimmed = line.trim();
126
+
127
+ // Match annotation: // @requirement REQ-ID
128
+ const annMatch = trimmed.match(/^\/\/\s*@requirement\s+([\w-]+)/);
129
+ if (annMatch) {
130
+ pendingReqs.push(annMatch[1]);
131
+ continue;
132
+ }
133
+
134
+ // Match PRISM property formula lines:
135
+ // P=?, P>0, P>=, Pmin=?, Pmax=?
136
+ // R{"..."}=?, R{...}=?, Rmin=?, Rmax=?
137
+ // S=?, S>0, etc.
138
+ // Also filter=? and multi=?
139
+ const isPrismProp = /^[PSR]/.test(trimmed) && (
140
+ /^P\s*[=<>!]/.test(trimmed) ||
141
+ /^P\s*\[/.test(trimmed) ||
142
+ /^Pmin\s*=/.test(trimmed) ||
143
+ /^Pmax\s*=/.test(trimmed) ||
144
+ /^R\s*\{/.test(trimmed) ||
145
+ /^R\s*[=<>]/.test(trimmed) ||
146
+ /^Rmin\s*=/.test(trimmed) ||
147
+ /^Rmax\s*=/.test(trimmed) ||
148
+ /^S\s*[=<>!]/.test(trimmed) ||
149
+ /^S\s*\[/.test(trimmed)
150
+ );
151
+
152
+ if (isPrismProp && pendingReqs.length > 0) {
153
+ results.push({
154
+ property: trimmed,
155
+ requirement_ids: [...pendingReqs]
156
+ });
157
+ pendingReqs = [];
158
+ continue;
159
+ }
160
+
161
+ // Comment lines (// ...) don't reset pending
162
+ const isComment = /^\/\//.test(trimmed);
163
+ const isBlank = /^\s*$/.test(trimmed);
164
+ if (!isComment && !isBlank && !isPrismProp) {
165
+ pendingReqs = [];
166
+ }
167
+ }
168
+
169
+ return results;
170
+ }
171
+
172
+ /**
173
+ * Get all properties (annotated or not) from a TLA+ file for validation.
174
+ * Uses section-based detection: only definitions in property/invariant/liveness
175
+ * sections are considered properties. Definitions in action/initial/next sections
176
+ * are skipped.
177
+ */
178
+ function getAllTLAProperties(content) {
179
+ const lines = content.split('\n');
180
+ const properties = [];
181
+ let pendingReqs = [];
182
+ let inPropertySection = false;
183
+
184
+ // Section markers that indicate property/invariant sections
185
+ const propertySectionMarkers = [
186
+ /safety\s*invariant/i, /liveness/i, /invariant/i,
187
+ /temporal\s*safety/i, /key\s*invariant/i, /safety\s*propert/i
188
+ ];
189
+ // Section markers that indicate non-property sections
190
+ const actionSectionMarkers = [
191
+ /actions/i, /initial\s*state/i, /next[\s-]*state/i,
192
+ /specification/i, /full\s*spec/i, /composite\s*actions/i,
193
+ /helper\s*predic/i, /ordering/i, /type\s*helper/i,
194
+ /type\s*invariant/i
195
+ ];
196
+
197
+ // Known infrastructure definitions (never properties)
198
+ const infraNames = new Set([
199
+ 'vars', 'Init', 'Next', 'Spec', 'N', 'NoAccount', 'NoPending',
200
+ 'AccountIds', 'OpTypes', 'FsmStates', 'AllSlots', 'AllSubsTried',
201
+ 'CanRecruit', 'VoteStates', 'FilterPhases', 'HaikuVerdicts',
202
+ 'AgentSymmetry', 'Slots', 'Outcomes', 'SlotStatuses', 'CallStates',
203
+ 'QuorumPhases', 'TypeInvariant', 'CountInState', 'AllSettled',
204
+ 'SuccessCount', 'CanStillReachThreshold',
205
+ 'AnyCollectVotes', 'AnyDeliberate', 'EnoughResponsive',
206
+ 'NoInvalidTransition'
207
+ ]);
208
+
209
+ for (const line of lines) {
210
+ // Detect section markers
211
+ const isSectionLine = /^\s*(\\\*|(\(\*))\s*[─━═\-]+/.test(line) ||
212
+ /^\s*\\\*\s*──/.test(line) ||
213
+ /^\s*\(\*\s*──/.test(line) ||
214
+ /^\s*\\\*\s*─── /.test(line);
215
+ if (isSectionLine || /^\s*\(\*/.test(line)) {
216
+ const lineText = line.toLowerCase();
217
+ if (propertySectionMarkers.some(m => m.test(lineText))) {
218
+ inPropertySection = true;
219
+ } else if (actionSectionMarkers.some(m => m.test(lineText))) {
220
+ inPropertySection = false;
221
+ }
222
+ }
223
+
224
+ // @requirement annotation always signals a property follows
225
+ const annMatch = line.match(/^\s*\\\*\s*@requirement\s+([\w-]+)/);
226
+ if (annMatch) {
227
+ pendingReqs.push(annMatch[1]);
228
+ continue;
229
+ }
230
+
231
+ const propMatch = line.match(/^(\w+)\s*==/);
232
+ if (propMatch) {
233
+ const name = propMatch[1];
234
+ if (infraNames.has(name)) {
235
+ pendingReqs = [];
236
+ continue;
237
+ }
238
+
239
+ // A definition is a property if:
240
+ // 1. It has @requirement annotations (always a property), OR
241
+ // 2. It's in a property section (invariant/liveness/safety)
242
+ const isProperty = pendingReqs.length > 0 || inPropertySection;
243
+ if (isProperty) {
244
+ properties.push({
245
+ property: name,
246
+ annotated: pendingReqs.length > 0,
247
+ requirement_ids: [...pendingReqs]
248
+ });
249
+ }
250
+ pendingReqs = [];
251
+ } else {
252
+ const isComment = /^\s*\\\*/.test(line) || /^\s*\(\*/.test(line) || /^\s*\*/.test(line);
253
+ const isBlank = /^\s*$/.test(line);
254
+ if (!isComment && !isBlank && !annMatch) {
255
+ pendingReqs = [];
256
+ }
257
+ }
258
+ }
259
+
260
+ return properties;
261
+ }
262
+
263
+ /**
264
+ * Get all assertions from an Alloy file for validation.
265
+ */
266
+ function getAllAlloyAssertions(content) {
267
+ const lines = content.split('\n');
268
+ const assertions = [];
269
+ let pendingReqs = [];
270
+
271
+ for (const line of lines) {
272
+ const annMatch = line.match(/^--\s*@requirement\s+([\w-]+)/);
273
+ if (annMatch) {
274
+ pendingReqs.push(annMatch[1]);
275
+ continue;
276
+ }
277
+
278
+ const assertMatch = line.match(/^assert\s+(\w+)/);
279
+ if (assertMatch) {
280
+ assertions.push({
281
+ property: assertMatch[1],
282
+ annotated: pendingReqs.length > 0,
283
+ requirement_ids: [...pendingReqs]
284
+ });
285
+ pendingReqs = [];
286
+ } else {
287
+ const isComment = /^--/.test(line.trim());
288
+ const isBlank = /^\s*$/.test(line);
289
+ if (!isComment && !isBlank) {
290
+ pendingReqs = [];
291
+ }
292
+ }
293
+ }
294
+
295
+ return assertions;
296
+ }
297
+
298
+ /**
299
+ * Get all PRISM properties from a .props file for validation.
300
+ */
301
+ function getAllPRISMProperties(content) {
302
+ const lines = content.split('\n');
303
+ const properties = [];
304
+ let pendingReqs = [];
305
+
306
+ for (const line of lines) {
307
+ const trimmed = line.trim();
308
+
309
+ const annMatch = trimmed.match(/^\/\/\s*@requirement\s+([\w-]+)/);
310
+ if (annMatch) {
311
+ pendingReqs.push(annMatch[1]);
312
+ continue;
313
+ }
314
+
315
+ const isPrismProp = /^[PSR]/.test(trimmed) && (
316
+ /^P\s*[=<>!]/.test(trimmed) ||
317
+ /^P\s*\[/.test(trimmed) ||
318
+ /^Pmin\s*=/.test(trimmed) ||
319
+ /^Pmax\s*=/.test(trimmed) ||
320
+ /^R\s*\{/.test(trimmed) ||
321
+ /^R\s*[=<>]/.test(trimmed) ||
322
+ /^Rmin\s*=/.test(trimmed) ||
323
+ /^Rmax\s*=/.test(trimmed) ||
324
+ /^S\s*[=<>!]/.test(trimmed) ||
325
+ /^S\s*\[/.test(trimmed)
326
+ );
327
+
328
+ if (isPrismProp) {
329
+ properties.push({
330
+ property: trimmed,
331
+ annotated: pendingReqs.length > 0,
332
+ requirement_ids: [...pendingReqs]
333
+ });
334
+ pendingReqs = [];
335
+ } else {
336
+ const isComment = /^\/\//.test(trimmed);
337
+ const isBlank = /^\s*$/.test(trimmed);
338
+ if (!isComment && !isBlank) {
339
+ pendingReqs = [];
340
+ }
341
+ }
342
+ }
343
+
344
+ return properties;
345
+ }
346
+
347
+ // ── Test File Parsing ───────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * Parse @requirement annotations from a JS test file.
351
+ * Pattern: // @requirement REQ-ID
352
+ * Associates with: test('...', ...) or describe('...', ...) when it appears on the next non-blank line after @requirement
353
+ * Intervening comments or non-blank-non-test lines break the association.
354
+ */
355
+ function parseTestFile(content) {
356
+ const lines = content.split('\n');
357
+ const results = [];
358
+ let pendingReqs = [];
359
+
360
+ // Two-pass: first collect all file-level @requirement annotations,
361
+ // then match them to the first test() call in the file.
362
+ // Also support inline: annotation immediately before test().
363
+ const fileReqs = [];
364
+
365
+ for (let i = 0; i < lines.length; i++) {
366
+ const trimmed = lines[i].trim();
367
+
368
+ const annMatch = trimmed.match(/^\/\/\s*@requirement\s+([\w-]+)/);
369
+ if (annMatch) {
370
+ pendingReqs.push(annMatch[1]);
371
+ continue;
372
+ }
373
+
374
+ const testMatch = trimmed.match(/^(?:test|describe)\s*\(\s*['"]([^'"]+)['"]/);
375
+ if (testMatch && pendingReqs.length > 0) {
376
+ results.push({
377
+ test_name: testMatch[1],
378
+ requirement_ids: [...pendingReqs]
379
+ });
380
+ pendingReqs = [];
381
+ }
382
+ }
383
+
384
+ return results;
385
+ }
386
+
387
+ /**
388
+ * Get all test files to scan.
389
+ * Scans hooks/*.test.js and bin/*.test.cjs
390
+ */
391
+ function getTestFiles() {
392
+ const testFiles = [];
393
+ const hooksPath = path.join(ROOT, 'hooks');
394
+ const binPath = path.join(ROOT, 'bin');
395
+
396
+ if (fs.existsSync(hooksPath)) {
397
+ try {
398
+ const hooksFiles = fs.readdirSync(hooksPath);
399
+ for (const file of hooksFiles) {
400
+ if (file.endsWith('.test.js')) {
401
+ testFiles.push('hooks/' + file);
402
+ }
403
+ }
404
+ // Scan generated-stubs in .planning/formal/
405
+ const stubsPath = path.join(ROOT, '.planning', 'formal', 'generated-stubs');
406
+ if (fs.existsSync(stubsPath)) {
407
+ const stubFiles = fs.readdirSync(stubsPath);
408
+ for (const file of stubFiles) {
409
+ if (file.endsWith('.test.js')) {
410
+ testFiles.push('.planning/formal/generated-stubs/' + file);
411
+ }
412
+ }
413
+ }
414
+ } catch (e) {
415
+ // Ignore read errors
416
+ }
417
+ }
418
+
419
+ if (fs.existsSync(binPath)) {
420
+ try {
421
+ const binFiles = fs.readdirSync(binPath);
422
+ for (const file of binFiles) {
423
+ if (file.endsWith('.test.cjs')) {
424
+ testFiles.push('bin/' + file);
425
+ }
426
+ }
427
+ } catch (e) {
428
+ // Ignore read errors
429
+ }
430
+ }
431
+
432
+ return testFiles;
433
+ }
434
+
435
+ /**
436
+ * Extract test annotations from all test files.
437
+ * Returns { "test:hooks/config-loader.test.js": [{ test_name, requirement_ids }], ... }
438
+ */
439
+ function extractTestAnnotations() {
440
+ const testFiles = getTestFiles();
441
+ const result = {};
442
+
443
+ for (const filePath of testFiles) {
444
+ const absPath = path.resolve(__dirname, '..', filePath);
445
+ if (!fs.existsSync(absPath)) continue;
446
+
447
+ const content = fs.readFileSync(absPath, 'utf8');
448
+ const annotations = parseTestFile(content);
449
+
450
+ if (annotations.length > 0) {
451
+ result['test:' + filePath] = annotations;
452
+ }
453
+ }
454
+
455
+ return result;
456
+ }
457
+
458
+ // ── Main ─────────────────────────────────────────────────────────────────────
459
+
460
+ function getModelFiles() {
461
+ const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
462
+ const models = Object.keys(registry.models);
463
+
464
+ return models.filter(modelPath => {
465
+ // Skip TTrace files
466
+ if (modelPath.includes('_TTrace_')) return false;
467
+
468
+ // Skip .pm files (PRISM model definitions — properties are in .props)
469
+ if (modelPath.endsWith('.pm')) return false;
470
+
471
+ // Skip paths that traverse outside the project (e.g., test paths)
472
+ if (modelPath.startsWith('..') || modelPath.startsWith('/')) return false;
473
+
474
+ // Check file exists on disk
475
+ const absPath = path.join(ROOT, modelPath);
476
+ return fs.existsSync(absPath);
477
+ });
478
+ }
479
+
480
+ function getPropsFiles() {
481
+ // Find .props files that are siblings of .pm files in the registry
482
+ const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
483
+ const models = Object.keys(registry.models);
484
+ const propsFiles = [];
485
+
486
+ for (const modelPath of models) {
487
+ if (modelPath.endsWith('.pm') && !modelPath.startsWith('..') && !modelPath.startsWith('/')) {
488
+ const propsPath = modelPath.replace(/\.pm$/, '.props');
489
+ const absPropsPath = path.join(ROOT, propsPath);
490
+ if (fs.existsSync(absPropsPath)) {
491
+ propsFiles.push(propsPath);
492
+ }
493
+ }
494
+ }
495
+
496
+ return propsFiles;
497
+ }
498
+
499
+ function extractAnnotations() {
500
+ const modelFiles = getModelFiles();
501
+ const propsFiles = getPropsFiles();
502
+ const allFiles = [...modelFiles, ...propsFiles];
503
+ const result = {};
504
+
505
+ for (const filePath of allFiles) {
506
+ const absPath = path.join(ROOT, filePath);
507
+ const content = fs.readFileSync(absPath, 'utf8');
508
+ const ext = path.extname(filePath);
509
+
510
+ let annotations;
511
+ if (ext === '.tla') {
512
+ annotations = parseTLA(content);
513
+ } else if (ext === '.als') {
514
+ annotations = parseAlloy(content);
515
+ } else if (ext === '.props') {
516
+ annotations = parsePRISM(content);
517
+ } else {
518
+ continue;
519
+ }
520
+
521
+ if (annotations.length > 0) {
522
+ result[filePath] = annotations;
523
+ }
524
+ }
525
+
526
+ return result;
527
+ }
528
+
529
+ function validate() {
530
+ const modelFiles = getModelFiles();
531
+ const propsFiles = getPropsFiles();
532
+ const allFiles = [...modelFiles, ...propsFiles];
533
+
534
+ let totalFiles = 0;
535
+ let totalProperties = 0;
536
+ let totalAnnotated = 0;
537
+ const unannotated = [];
538
+
539
+ for (const filePath of allFiles) {
540
+ const absPath = path.join(ROOT, filePath);
541
+ if (!fs.existsSync(absPath)) continue;
542
+
543
+ const content = fs.readFileSync(absPath, 'utf8');
544
+ const ext = path.extname(filePath);
545
+
546
+ let allProps;
547
+ if (ext === '.tla') {
548
+ allProps = getAllTLAProperties(content);
549
+ } else if (ext === '.als') {
550
+ allProps = getAllAlloyAssertions(content);
551
+ } else if (ext === '.props') {
552
+ allProps = getAllPRISMProperties(content);
553
+ } else {
554
+ continue;
555
+ }
556
+
557
+ totalFiles++;
558
+ totalProperties += allProps.length;
559
+ totalAnnotated += allProps.filter(p => p.annotated).length;
560
+
561
+ for (const prop of allProps) {
562
+ if (!prop.annotated) {
563
+ unannotated.push({ file: filePath, property: prop.property });
564
+ }
565
+ }
566
+ }
567
+
568
+ const totalUnannotated = unannotated.length;
569
+
570
+ console.log(`VALIDATION: ${totalFiles} files scanned, ${totalProperties} properties found, ${totalAnnotated} annotated, ${totalUnannotated} unannotated`);
571
+
572
+ if (totalUnannotated > 0) {
573
+ console.log('UNANNOTATED:');
574
+ for (const u of unannotated) {
575
+ console.log(` ${u.file}: ${u.property} (no @requirement annotation)`);
576
+ }
577
+ console.log('FAIL');
578
+ process.exit(1);
579
+ } else {
580
+ console.log('OK');
581
+ process.exit(0);
582
+ }
583
+ }
584
+
585
+ function summary() {
586
+ const result = extractAnnotations();
587
+ let totalProps = 0;
588
+ let totalReqLinks = 0;
589
+
590
+ console.log('File summary:');
591
+ for (const [file, annotations] of Object.entries(result).sort(([a], [b]) => a.localeCompare(b))) {
592
+ const props = annotations.length;
593
+ const links = annotations.reduce((s, a) => s + a.requirement_ids.length, 0);
594
+ totalProps += props;
595
+ totalReqLinks += links;
596
+ console.log(` ${file}: ${props} properties, ${links} requirement links`);
597
+ }
598
+ console.log(`\nTotal: ${Object.keys(result).length} files, ${totalProps} properties, ${totalReqLinks} requirement links`);
599
+ }
600
+
601
+ // ── CLI ──────────────────────────────────────────────────────────────────────
602
+
603
+ if (require.main === module) {
604
+ const args = process.argv.slice(2);
605
+
606
+ if (args.includes('--validate')) {
607
+ validate();
608
+ } else if (args.includes('--summary')) {
609
+ summary();
610
+ } else {
611
+ const result = extractAnnotations();
612
+ if (args.includes('--include-tests')) {
613
+ const testAnnotations = extractTestAnnotations();
614
+ Object.assign(result, testAnnotations);
615
+ }
616
+ if (args.includes('--pretty')) {
617
+ console.log(JSON.stringify(result, null, 2));
618
+ } else {
619
+ console.log(JSON.stringify(result));
620
+ }
621
+ }
622
+ }
623
+
624
+ // ── Exports ──────────────────────────────────────────────────────────────────
625
+
626
+ module.exports = {
627
+ parseTLA,
628
+ parseAlloy,
629
+ parsePRISM,
630
+ parseTestFile,
631
+ extractAnnotations,
632
+ extractTestAnnotations,
633
+ };